【C++杂货铺】国庆中秋特辑——多态由浅入深详细总结
文章目录
- 一、多态的概念
- 二、多态的定义及实现
- 2.1 多态的构成条件
- 2.2 虚函数
- 2.3 虚函数的重写
- 2.4 虚函数重写的两个例外
- 2.4.1 协变(基类与派生类虚函数返回值类型不同)
- 2.4.2 析构函数的重写(基类与派生类析构函数的名字不同)
- 2.5 C++11 override 和 final
- 2.5.1 final:修饰虚函数,表示该虚函数不能再被重写
- 2.5.2 override
- 三、重载、隐藏(重定义)、覆盖(重写)的对比
- 四、多态的原理
- 4.1 虚函数表
- 4.2 派生类对象中的虚函数表
- 4.2.1 编写程序去访问虚函数表
- 4.2.2 虚表存储位置的验证
- 4.3 多态的原理
- 4.3.1 为什么不能是派生类的指针或者引用?
- 4.3.2 为什么不能是父类的对象呢?
- 4.3.3 派生类中为什么要对父类的虚函数进行重写?
- 4.4 动态绑定与静态绑定
- 五、多继承关系的虚函数表
- 5.1 普通的多继承
- 5.2 菱形继承、菱形虚拟继承
- 5.2.1 普通菱形继承
- 5.2.2 菱型虚拟继承
- 六、抽象类
- 6.1 概念
- 6.2 接口继承和实现继承
- 七、多态常见面试题
- 7.1 快问快答
- 八、结语
一、多态的概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
举个栗子:比如买票这个行为,当普通人买票时,是全价;学生买票时,是半价;军人买票时是优先买票。再举个例子:想必大家都参与过支付宝的扫红包-支付-给奖励金的活动,那么大家想一想为什么有人扫的红包金额很大8块、10块,而有的人扫出来的红包金额都是1毛,5毛。其实这背后就是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、或者你没有经常的使用支付宝等等,那么你需要被鼓励使用支付宝,那么你扫码的金额就 = random % 99;如果你是经常使用支付宝支付或者支付宝账户中常年有钱,那么就不需要太鼓励你去使用支付宝,那么你的扫码金额就 = random % 1。总结一下:同样是扫码动作,不同的用户去扫得到不一样的红包,这也是一种多态行为。
二、多态的定义及实现
2.1 多态的构成条件
多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。比如 Student 继承了 Person。Person 对象买票全价,Student 对象买票半价。因此多态的前提是要在继承体系中,在继承中要构成多态还有两个条件:
-
必须通过基类的指针或者引用调用虚函数
-
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
class Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买全价票" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买半价票" << endl;}
};void Func(const Person& people)
{people.BuyTicket();
}int main()
{Person Jack;//普通人Func(Jack);Student Mike;//学生Func(Mike);return 0;
}
小Tips:多态调用看的是基类指针或引用指向的对象,基类的指针或引用如果指向一个基类对象,那就调用基类的成员函数,如果指向派生类对象就调用派生类的成员函数。
class Person
{
public:virtual void BuyTicket() const{cout << "买全价票" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() const{cout << "买半价票" << endl;}
};void Func(const Person people)
{people.BuyTicket();
}int main()
{Person Jack;//普通人Func(Jack);Student Mike;//学生Func(Mike);return 0;
}
小Tips:上面这段代码中 Fun
函数的形参变成了一个普通的基类对象 people,在函数体中通过 people 去调用成员函数 BuyTicket,此时因为 people 不是基类的指针或引用,因此 people.BuyTicket();
函数调用不满足多态调的条件,此时无论传进来的是基类对象还是派生类对象,调用的都是基类中的 BuyTicket,因为在不满足多态的条件下,调用成员函数取决于当前调用对象的类型,当前的 people 是一个基类对象,这就意味着它只能调用基类中的成员函数,所以我们不管是传基类对象 Jack 还是派生类对象 Mike,最终打印结果都是“买全价票”。传派生类对象的 Mike 的时候,会发生切片,会用 Mike 对象中继承自基类的那部分成员变量去构造基类对象 people。如果 Fun 函数的形参 people 就是基类的指针或者引用,去掉基类中 BuyTicket 函数前面的 virtual,此时还是不满足多态的条件,无论传基类对象还是派生类对象,最终调用的都是基类中的 BuyTicke,因为 people 的类型是基类。总结:多态的两个构成条件缺一不可。
2.2 虚函数
虚函数:被 virtual 修饰的类成员函数称为虚函数。
class Person
{
public:virtual void BuyTicket() const{cout << "买全价票" << endl;}
};
小Tips:只能是类的成员函数才能变成虚函数,在全局函数前面是不能加 virtual 的。
2.3 虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
class Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买全价票" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() const//虚函数{cout << "买半价票" << endl;}
};void Func(const Person& people)
{people.BuyTicket();
}int main()
{Person Jack;//普通人Func(Jack);Student Mike;//学生Func(Mike);return 0;
}
小Tips:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
2.4 虚函数重写的两个例外
2.4.1 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类(也可以是其他继承体系中的基类)对象的指针或者引用,派生类虚函数返回派生类(也可以是其他继承体系中的派生类)对象的指针或者引用,这就称作协变。返回值类型必须同时是指针或者引用,不能一个是指针,一个是引用。
class A
{};class B : public A
{};class Person
{
public:virtual A* f() { return new A; }
};class Student : public Person
{
public:virtual B* f() { return new B; }
};
2.4.2 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数的名字不同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor。
class Person
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};class Student : public Person
{
public:virtual ~Student() { cout << "~Student()" << endl;delete[] pi;pi = nullptr;}
protect:int* pi = new int[10];
};void Test()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;
}int main()
{Test();return 0;
}
小Tips:编译器之所以将所有类的析构函数都统一处理成 destructor,目的是为了让父子类的析构函数构成重写,只有派生类 Student 的析构函数重写了 Person 的析构函数,上面代码中 delete 对象,才能构成多态,才能保证 p1 和 p2 指向的对象正确的调用析构函数。假如子类中并没有重写父类的析构函数,那么 delete p2;
就会出问题,它就调不到派生类 Student 的析构函数。因为 delete 分两步,先去调用析构函数,再去调用 operator delete
,而这里 p2
是一个基类 Person 的对象,最终 delete p2
就是变成:p2->destructor + operator delete(p2)
。如果派生类 Student 没有重写基类 Person 的析构函数,那 p2->destructor
就不构成多态调用,就是普通的调用成员函数,此时会根据调用对象的类型去判断到底是调用基类中的成员函数还是调用派生类中的成员函数(具体规则是基类对象调用基类的成员函数,派生类对象调用派生类中的成员函数),这里的 p2
是一个基类对象的指针,所以 p2->destructor
调用的一定是基类的析构函数,但是当前 p2
指向一个派生类 Student 的对象,而我们希望调用派生类 Student 的析构函数去清理该派生类 Student 对象中的资源。 这种情况下,我们希望的是 p2
指向谁,就去调用谁的析构,这不就是多态嘛。所以我们要让基类的析构函数变成虚函数,然后派生类去重写虚函数,这样才能满足多态的条件,重写编译器已经帮我们实现了(编译器将析构函数统一处理成同名函数,且析构函数没有返回值和参数,完美的满足三通),我们只需要在基类析构函数的前面加上 virtual,让析构函数变成虚函数即可。这里建议大家在写代码的过程中,对于可能会被继承的类最好在它的析构函数前面加上 virtual,让它变成一个虚函数。
2.5 C++11 override 和 final
从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名的字母顺序写反而无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。
2.5.1 final:修饰虚函数,表示该虚函数不能再被重写
final:修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; }
};
小Tips:虚函数如果不能被重写是没有什么意义的。这里在补充一个知识点,一个类不想被继承该怎么做?C++98 中的方法:将该类的构造函数私有,私有在子类中是不可见的,而派生类的构造函数又必须调用父类的构造函数。但是这种做法会导致创建该类对象时也无法调用构造函数了,私有在类外面不可见但是在类里面是可见的,所以此时可以在该类里面写一个静态成员函数专门用来创建对象。在 C++11 引入 final 关键字后,对于一个类如果不想让它被继承,我们可以在该类的后面加上 final 关键字进行修饰。
2.5.2 override
override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car
{
public:virtual void Drive() {}
};
class Benz :public Car
{
public:virtual void Drive() override{ cout << "Benz-舒适" << endl; }
};
三、重载、隐藏(重定义)、覆盖(重写)的对比
四、多态的原理
4.1 虚函数表
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};int main()
{cout << sizeof(Base) << endl;return 0;
}
小Tips:通过上面的打印结果和调试,我们发现一个 Base 对象是 8 bytes,除了 _b
成员,还多了一个 _vfptr
放在对象成员变量的前面(注意有些平台可能会放到对象成的最后面,这个跟平台有关系)。_vfptr
本质上是一个指针,这个指针我们叫做虚函数表指针(v 代表 virtual,f 代表 function)。一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(虚函数本质上是存在代码段的),虚函数表也简称虚表。
4.2 派生类对象中的虚函数表
上面我们看了一个普通类对象中的虚表,下面我们再来看看派生类中的虚表又是怎样的。
// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};
class Derive : public Base
{
public:virtual void Func1(){}
private:int _d = 2;
};
int main()
{Base b;Derive d;return 0;
}
通过监视窗口我们发现了以下几个问题:
-
派生类对象 d 中也有一个虚表,但是这个虚表是作为基类成员的一部分被继承下来的。总的来说,d 对象由两部分构成,一部分是父类继承下来的成员,d 对象中虚表指针就是就是这部分成员中的一个。另一部分则是自己的成员。
-
基类 b 对象和派生类 d 对象的虚表是不一样的,上面的代码中
Func1
完成了重写,所以 d 的虚表中存的是重写后的Derive::Func1
,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层面的叫法,覆盖是原理层面的叫法。 -
另外
Func2
继承下来后是虚函数,所以放进了虚表,Func3
也继承下来了,但是不是虚函数,所以不会放进虚表。 -
虚函数表本质上是一个存虚函数地址的函数指针数组,一般情况下这个数组最后面放了一个 nullptr。
-
总结一下派生类虚表的生成:
-
先将基类中的虚表内容拷贝一份到派生类虚表中。
-
如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
-
派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(在 VS 监视窗口显示的虚表中是看不见的,下面将通过程序带大家来验证)
-
-
这里还有一个比较容易混淆的问题:虚函数存在哪?虚表存在哪?很多小伙伴会觉得:虚函数存在虚表,虚表存在对象中,注意这种回答是错的。这里再次强调:虚表存的是虚函数的地址,不是虚函数,虚函数和普通的成员函数一样,都是存在代码段的,只是它的地址又存到了虚表中。另外,对象中存的不是虚表,存的是虚表的地址。那虚表是存在哪儿呢?通过验证,在 VS 下虚表是存在代码段的。Linux g++ 下大家可以自己去验证。
-
同一个程序中,同一类型的对象共用一个虚表。
4.2.1 编写程序去访问虚函数表
上面提到派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。但是在 VS 的监视窗口中是看不到,以下面的代码为例:
class Person
{
public:virtual void func1() const{cout << "virtual void Person::fun1()" << endl;}virtual void func2() const{cout << "virtual void Person::fun2()" << endl;}virtual void func3() const{cout << "virtual void Person::fun3()" << endl;}//protected:int _a = 1;
};class Student : public Person
{
public:virtual void func1() const{cout << "virtual void Student::fun1()" << endl;}virtual void func3() const{cout << "virtual void Student::fun3()" << endl;}virtual void func4() const{cout << "virtual void Student::fun4()" << endl;}//protected:int _b = 2;
};int main()
{Person Mike;Student Jack;
}
小Tips:监视窗口中展现的派生类对象的虚函数表中并没有派生类自己的虚函数 func4。但是我们从内存窗口可以看到第四个地址,我们可以大胆的猜测这个就是派生类自己的虚函数 func4 的地址,但是口说无凭,下面我们来写一段代码验证一下我们的猜想。
typedef void (*VFPTR) ();//VFPTR是一个函数指针//vf是一个函数指针数组,vf就是指向虚表
//虚表本质上就是一个函数指针数组
void PrintVfptr(VFPTR* vf)
{for (int i = 0; vf[i] != nullptr; i++){printf("vfptr[%d]:%p----->", i, vf[i]);VFPTR f = vf[i];//函数指针和函数名是一样的,可以去调用该函数f();}printf("\n");
}int main()
{Person Mike;int vfp1 = *(int*)&Mike;PrintVfptr((VFPTR*)vfp1);Student Jack;int vfp2 = *(int*)&Jack;PrintVfptr((VFPTR*)vfp2);return 0;
}
小Tips:通过上图可以看出我们程序打印出来的地址和监视窗口中显示的地址是一样的,并且成功的调用了派生类中的虚函数 func4,上图显示的结果完美的验证了我们的猜想。这里也说明了一个问题,VS 的监视窗口是存在 Bug 的,以后我们在调试代码过程中也不能完全相信监视窗口展现给我们的内容,比起监视窗口我们更应该相信内存窗口展现给我们的内容。这里也侧面反映了一个问题,只要我们能拿到函数的地址就能去调用该函数,正常情况下,我们只能通过派生类对象去调用虚函数 func4,这里我们直接拿到了这个函数的地址去调用,这里的问题在于函数的隐藏形参 this 指针接收不到实参,因为不是派生类对象去调用该函数。函数中如果去访问了成员变量,那么我们这种调用方式就会出问题。
4.2.2 虚表存储位置的验证
//虚表存储位置的验证class Person
{
public:virtual void func1() const{cout << "virtual void Person::fun1()" << endl;}
//protected:int _a = 1;
};class Student : public Person
{
public:virtual void func1() const{cout << "virtual void Student::fun1()" << endl;}
//protected:int _b = 2;
};int main()
{Person Mike;Student Jack;//栈区int a = 10;printf("栈区:%p\n", &a);//堆区int* pa = new int(9);printf("堆区:%p\n", pa);//静态区(数据段)static int sa = 8;printf("静态区(数据段):%p\n", &sa);//常量区(代码段)const char* pc = "hello word!";printf("常量区(代码段):%p\n", pc);//虚表printf("基类的虚表:%p\n", (void*)*(int*)&Mike);printf("派生类的虚表:%p\n", (void*)*(int*)&Jack);
}
小Tips:上面取虚表地址是通过强制类型转化来实现的,通过上面的监视窗口我们可以看出,虚表的地址永远是存储在对象的前四个字节,所以这里我们先取到对象的地址,然后将其强转为 int* 类型,为什么要强转为 int* 呢?因为,一个 int 型的大小就是四个字节,而指针的类型决定了该指针能够访问到内存空间的大小,一个 int* 的指针就能够访问到四个字节,再对 int* 解引用,这样就能访问到内存空间中前四个字节的数据,这样就能取道虚表的地址啦。通过打印结果我们可以看出,虚表的地址和常量区(代码段)的地址最为接近,因此我们可以大胆的猜测,虚表就是存储在常量区(代码段)的。
4.3 多态的原理
上面说了这么多,那多态的原理究竟是什么呢?
小Tips:此时再来分析下上面这个图,当 people 指向基类对象 Jack 时,people.BuyTicket()
在 Jack 的虚表中找到的虚函数是 Person::BuyTicket()
;当 people 指向派生类对象 Mike 时,people.BuyTicket()
在 Mike 的虚表中找到的虚函数是 Student::BuyTicket()
。这样就实现了不同对象去完成同一行为时,展现出不同的形态。其次,通过对汇编代码的分析,可以发现,满足多态的函数调用,不是在编译时确定的,是在运行起来以后到对象中取的。而不满足多态的函数调用则是在编译时就确定好了。
小Tips:通过上面两张图可以看出,在满足多态的条件下,无论传递的是基类对象还是派生类对象,最终转化成汇编代码都是一样的。最终的函数调用是在代码运行起来后去对象里面取的。
小Tips:普通函数调用在编译时就确定好了直接去 call 那个函数。call 的这个函数和调用该函数对象的类型有关,这里调用 BuyTicket 的对象是一个 Person 类型,这就决定了调用的 BuyTicket 函数一定是基类中的。
4.3.1 为什么不能是派生类的指针或者引用?
答:因为只有基类的指针和引用才能做到既可以指向基类对象也可以指向派生类对象。而一个派生类的指针或者引用,只能指向派生类对象,不能指向基类对象。
4.3.2 为什么不能是父类的对象呢?
答:因为如果是一个父类对象,假定为 A,那么将一个派生类对象赋值给父类对象 A 时,会发生切片,会用该派生类中父类的那部分成员变量的值去初始化该父类对象 A,但是并不会把该派生类对象中的虚表拷贝给父类对象,所以不管是将基类对象赋值给基类对象 A,还是将一个派生类对象赋值给基类对象 A,该基类对象 A 中的虚表永远都是基类自己的,去调用的始终是基类自己的虚函数,无法做到传基类调用基类的虚函数,传派生类调用派生类的虚函数,多态就无法实现。而父类的指针和引用之所以能够实现,是因为父类对象的指针和引用指向一个父类对象当然是没问题的,指向派生类对象时,会发生形式上的切片,即这种切片并不是真的切片,假设这里有一个基类的指针 p,此时它指向一个派生类对象,这里的切片本质上是限定了 p 指针的“视野范围”,即 p 指针只能“看到”该派生类对象中继承自父类的那部分成员,并没有像前面那样去实实在在的重新创建一个基类对象。而且根据 4.2 小节那张监视窗口的截图我们可以发现,派生类的虚表本质上是作为父类成员的一部分继承下来的,但是会对该虚表中的内容稍作修改(具体如何修改请看 4.2 小节),使之称为派生类自己的虚表,所以 p 指针指向一个派生类对象的时候,就能去根据派生类的虚表去调用派生类自己的虚函数。这样才能满足多态的要求。
class Person
{
public:virtual void func1() const{cout << "virtual void Person::fun1()" << endl;}virtual void func2() const{cout << "virtual void Person::fun2()" << endl;}virtual void func3() const{cout << "virtual void Person::fun3()" << endl;}
//protected:int _a = 1;
};class Student : public Person
{
public:virtual void func1() const{cout << "virtual void Student::fun1()" << endl;}virtual void func3() const{cout << "virtual void Student::fun3()" << endl;}virtual void func4() const{cout << "virtual void Student::fun4()" << endl;}
//protected:int _b = 2;
};int main()
{Person Mike;Student Jack;Jack._a = 9;Mike = Jack;
}
小Tips:这里如果把虚函数表也拷贝过去那就乱套了,如果真拷贝过去了,那当一个基类的指针(Person*)指向 Mike 时,去调用的就是派生类的虚函数,而且有一些虚函数是派生类自己的,那这也太离谱了吧,一顿操作下来,一个基类的指针既然能去调用一个自己这个类里面没有的函数。太离谱了,太离谱了,千万不能这样搞。这里总结一下:就是想告诉大家,将一个派生类对象赋值给基类对象的过程中,会涉及到切片,但是不会把虚表拷贝过去的。
4.3.3 派生类中为什么要对父类的虚函数进行重写?
答:派生类中的虚表本质上是继承自父类的,会先把父类的虚表拷贝一份,如果对父类的虚函数进行重写了,那么就会对拷贝的虚表进行修改,存派生类重写的虚函数地址。如果派生类没有对基类的虚函数进行重写,那么派生类的虚表中存的就是从基类虚表中拷贝过来的基类虚函数的地址,这就失去了多态原本的目的,还没有意义的。
4.4 动态绑定与静态绑定
-
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
-
动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
-
4.3 小节中汇编代码的截图就很好的展示了什么是静态(编译器)绑定和动态(运行时)绑定。
五、多继承关系的虚函数表
5.1 普通的多继承
上面我们都是在单继承体系中去探究虚函数表的,那多继承关系中的虚函数表是怎么样的呢?下面我们就来一探究竟。根据前面的经验,监视窗口展示给我们的内容已经不能再相信了,所以这里我们直接通过程序去打印内存空间中虚表里面的虚函数地址。
class Base1
{
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1 = 0;
};class Base2
{
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2 = 2;
};class Derive : public Base1, public Base2
{
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1 = 3;
};typedef void(*VFPTR) ();void PrintVTable(VFPTR vTable[])
{cout << "虚表地址:" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("vTable[%d]:0X%p--------->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
int main()
{Derive d;VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb2);return 0;
}
小Tips:通过打印结果可以发现,对于多继承的派生类 Derive 来说,它的对象里面会有两张虚表,因为它继承了两个类,这两个类中一张继承自 Base1,另一张继承自 Base2,派生类自己的虚函数地址会存放在继承的第一个基类的虚表中。此外还有一个值得注意的地方:两个基类中都有 func1 函数,并且它们的返回值类型,函数名、参数都完全相同,派生类中对这个 func1 函数进行了重写,原本继承下来的虚表中存的都是他们自己内部 func1 函数的地址,派生类进行重写后,两张虚表中 func1 函数的地址就应该被覆盖成派生类中 func1 函数的地址,但是通过打印结果可以看出两张虚表中存的 func1 函数的地址并不相同,但是最终调用的却是同一个函数,都去调用了派生类中重写的 func1 函数,这是为什么呢?通过下面这段代码的反汇编来给大家解释原因。
int main()
{Derive d;Base1* p1 = &d;p1->func1();Base2* p2 = &d;p2->func1();
}
小Tips:通过反汇编我们可以看出,p1 是直接去调用的,p2 则进行了多层封装。p2 调用进行多层封装的主要目的就是为了执行 sub ecx , 8
,这里的 ecx
是一个寄存器,它存的是 this
指针的值,那为什么要对它减 8 呢?我们先来看看在减 8 之前 ecx
中存的是什么值。
小Tips:我们可以发现 ecx
本来存的是 p2
指针的值,那为什么要对这个值减 8 呢?因为 p2 本来是一个基类的指针,而 fucn1 函数中的隐藏形参 this 是一个派生类的指针。一个基类指针是不能赋值给派生类的指针,换句话说就是一个派生类的指针不能指向一个基类对象,原因是指针的类型决定了该指针可以访问到的内容,一个派生类指针应该可以访问到派生类中的所有成员,而当一个派生类指针指向一个基类对象的时候,由于基类对象中不可能有派生类中的成员,所以派生类指针再去访问这些成员的时候就会出错。这里的 8 本质上是一个 Base1 类对象的大小,所以这里减 8 的目的就是为了让 p2 中存 d 对象的首地址,这样 p2 就相当于指向了一个派生类(Derive)对象,此时再去调用 func1 函数就没有什么问题啦。所以总结一下,Derive 中只重写了一份 func1 函数,这里 sub ecx , 8
的目的就是为了修正 this
指针。p1 不用修正的原因是 p1 中原本存的就是 d 对象的首地址,去调用 func1 是没有任何问题的。其次补充一点,这里的 p1 和 p2 去调用 func1 函数都属于多态调用。(上面这种是 VS 下的解决办法,其他编译器的处理方法可能会有所不同)。
5.2 菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承和菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定的性能损耗。所以菱形继承、菱形虚拟继承的虚表我们就不需要研究的很清楚,因为始终很少使用。若果对这方面感兴趣的小伙伴这里我给大家推荐两篇文章:C++虚函数表解析、C++对象的内存布局
5.2.1 普通菱形继承
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}virtual void func2(){cout << "A::func2()" << endl;}
protected:int _a = 1;
};class B : public A
{
public:virtual void func1(){cout << "B::func1()" << endl;}virtual void func3(){cout << "B::func3()" << endl;}
protected:int _b = 2;
};class C : public A
{
public:virtual void func1(){cout << "C::func1()" << endl;}virtual void fun4(){cout << "C::func4()" << endl;}
protected:int _c = 3;
};class D : public B, public C
{
public:virtual void func1(){cout << "D::func1()" << endl;}virtual void func3(){cout << "D::func3()" << endl;}virtual void fun5(){cout << "D::func5()" << endl;}
protected:int _d = 4;
};typedef void(*VFPTR) ();void PrintVTable(VFPTR vTable[])
{cout << "虚表地址:" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("vTable[%d]:0X%p--------->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}int main()
{D d;B* p1 = &d;PrintVTable((VFPTR*)*(int*)p1);C* p2 = &d;PrintVTable((VFPTR*)*(int*)p2);
}
小Tips:普通菱形继承的虚表和多继承是如出一辙的没有什么区别。
5.2.2 菱型虚拟继承
class A
{
public:virtual void func1(){cout << "A::func1()" << endl;}virtual void func2(){cout << "A::func2()" << endl;}
//protected:int _a = 1;
};class B : virtual public A
{
public:virtual void func1(){cout << "B::func1()" << endl;}virtual void func3(){cout << "B::func3()" << endl;}
//protected:int _b = 2;
};class C : virtual public A
{
public:virtual void func1(){cout << "C::func1()" << endl;}virtual void fun4(){cout << "C::func4()" << endl;}
//protected:int _c = 3;
};class D : public B, public C
{
public:virtual void func1(){cout << "D::func1()" << endl;}virtual void func3(){cout << "D::func3()" << endl;}virtual void fun5(){cout << "D::func5()" << endl;}
//protected:int _d = 4;
};typedef void(*VFPTR) ();void PrintVTable(VFPTR vTable[])
{cout << "虚表地址:" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf("vTable[%d]:0X%p--------->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}void Test()
{D d;B* p1 = &d;PrintVTable((VFPTR*)*(int*)p1);C* p2 = &d;PrintVTable((VFPTR*)*(int*)p2);A* p3 = &d;PrintVTable((VFPTR*)*(int*)p3);
}int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;Test();
}
小Tips:从打印结果和上图可以看出,在菱形虚拟继承体系中 A 类中的成员被独立出来了,不再是 B 类和 C 类中各存一份了,因此在 B类、C类、D类对象中各自有一份 A 类的虚函数表。这里有一个问题,就是如果 B 类和 C 类中同时重写了 A 类中的虚函数,那么 D 类中一定也要重写这个虚函数,如上面代码中的 func1 函数,因为如果 D 类中不进行重写的话,那 D 类对象中到底存 B 类中重写的那个还是存 C 类中重写的那个呢,此时就会产生歧义,只要 D 类中也对这个虚函数进行重写,就不会产生歧义了。其次,D 类中并没有自己的虚表,即对 D 类自己的虚函数来说,编译器会把这个函数的地址存入 D 类继承的第一个类的虚表中,这里就是存入 B 类虚表中。
补充:上一篇文章中提到,虚基表中存的是偏移量,目的是为了找到被分离出去的基类成员,这里也就是 A 类成员,但是当时通过内存窗口看到,这个偏移量存在虚基表的第二个字节中,那虚基表的第一个字节存的是什么呢?答案是:存的也是偏移量,存这个偏移量的目的是为了找到该类在内存中的首地址,还是以上面的代码为例,因为一个类如果有虚表,那么虚表地址都是被存储在这个类对象的最开始位置,虚基表中第一个字节存储的偏移量是用来找到该对象的首地址,虚基表中第二个字节存储的偏移量是用来找到基类的首地址。
六、抽象类
6.1 概念
在虚函数的后面写上 =0
,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
//抽象类(接口)
class Car
{
public:virtual void Drive() const = 0;
};class Benz : public Car
{
public:virtual void Drive() const{cout << "Benz-舒适" << endl;}
};class Bmw : public Car
{
public:virtual void Drive() const{cout << "Bmw-操控" << endl;}
};void Advantages(const Car& car)
{car.Drive();
}int main()
{Benz be;Advantages(be);Bmw bm;Advantages(bm);
}
6.2 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类的函数,可以使用函数,继承的函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
七、多态常见面试题
//下面这段代码的运行结果是什么?
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main()
{B* p = new B;p->test();return 0;
}
分析:这道题要想做对,我们必须了解以下几点。首先就是我们对继承的理解,B 类继承了 A 类,因此一个 B 类的指针 p 去调用 test 是没有问题的,B 类中把 A 类的 test 函数给继承了下来。在 test 函数中又去调用了 func 函数,这个 func 函数本质上是又 this 指针去调用的,而 func 函数是一个虚函数,并且子类对其进行了重写,那这里调用 func 函数是否是多态调用呢?是不是多态调用取决于这里调用 func 函数的是谁,前面说过这里的 func 本质上是 this 指针去调用,那这里的 this 指针究竟是什么类型呢?如果是基类(A类型),那么这里就符合多态调用,如果是派生了(B类型),那就不符合多态调用。所以这里的 this 究竟是什么类型呢?这就要考察大家对继承的理解了。先说答案,这里的 this 指针是 A* 类型。可能会有很多朋友觉得,B 类继承了 A 类,那么就要在 B 类中就会重新生成一份 test 函数,然后这里的 p 指针就去调用 B 类中字节生成的 test 函数,所以这里的 this 指针因该是 B* 类型,但事实并非如此,编译器并不会这样做。继承中派生类对象模型是按照下面的方式来生成的,对于成员变量来说,创建一个派生类(这里就是B类)对象,它分为两个部分,第一部分是父类,第二部分是自己,他会把继承自父类中的那些成员变量凑在一起当成一个父类对象,然后又把这个对象当成是派生类的一个成员变量,因此在派生类构造函数的初始化列表中要去调用父类的构造函数,在派生类的析构函数中要去调用父类的析构。这就是一个派生类对象在内存中的存储模型,对象的存储模型只和成员变量有关,和成员函数无关。所有编译好的函数都是放在代码段的,由于派生类 B 中并没有对 test 函数进行重写,所以 test 函数的代码并不会生成两份,从始至终这个 test 函数就只有一份,即基类 A 生成的,所以这里的 test 函数中的 this 指针是 A*。p 指针在调用 test 函数的时候,先进行语法检查,先在派生类 B 中去找 test 函数,没找到接着去父类 A 中去找,最后找到了,语法上没有任何问题,然后在链接阶段,这个 test 函数是父类的,编译器就拿着这个经过函数名修饰规则修饰产生的名字去找这个函数。前面说了这么多,就是想告诉大家这里的 this 指针是 A*,所以这里满足多态调用。这就意味着不同类型的对象去调用 test 函数会产生不同的效果,基类对象去调用 test 函数最终会去调用基类中的 func 函数,派生类对象去调用 test 函数最终会去调用派生类中的 func 函数。而这里是一个派生类的指针 p 去调用 test 函数,所以最终调用的是派生类中的 func 函数,此时就会有小伙伴产生疑问了,派生类中 func 函数的形参 val 的缺省值明明是 0 呀,为什么打印出来的是1?1 不是父类中 func 函数形参的缺省值嘛。这就涉及到本题的第二个“坑点”了:虚函数重写,重写的是实现(只重写了函数体),这就是为什么派生类中重写的虚函数可以不加 virtual。对于重写的虚函数,编译器会检查是否满足三同,即返回值类型、函数名、参数列表是否相同(参数列表相同指的是参数的个数相同、类型顺序相同)。只要符合三同编译器就不管了,派生类中重写的虚函数的整个壳子(即函数声明那一套)使用的是父类中的。所以,派生类中重写的虚函数 func,它的函数体中使用的 val,就应该是父类中 val 的缺省值,在派生类重写的虚函数 func 的形参列表给缺省值是没有任何意义的。
7.1 快问快答
● inline 函数可以是虚函数嘛?
答:可以,不过编译器会忽略 inline 属性,这个函数就不再是 inline,因为虚函数要放进虚函数表中。
● 静态成员可以是虚函数嘛?
答:不能,因为静态成员函数没有 this 指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
● 构造函数可以是虚函数嘛?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
● 析构函数可以是虚函数嘛?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。集体场景参考 2.4.2 小节。
● 对象访问普通函数块还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数更快。因为构成多态,运行时调用虚函数要到虚函数表中去查找。
● 虚函数表是在什么阶段生成的?存在哪?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)。
● 什么是抽象类?抽象类的作用?
答:什么是抽象类请参考 6.1 小节。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
八、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!
相关文章:
【C++杂货铺】国庆中秋特辑——多态由浅入深详细总结
文章目录 一、多态的概念二、多态的定义及实现2.1 多态的构成条件2.2 虚函数2.3 虚函数的重写2.4 虚函数重写的两个例外2.4.1 协变(基类与派生类虚函数返回值类型不同)2.4.2 析构函数的重写(基类与派生类析构函数的名字不同) 2.5 …...
MongoDB基础详解
一、MongoDB概述 MongoDB 是一个基于 分布式文件存储 的开源 NoSQL 数据库系统,由 C 编写的。MongoDB 提供了 面向文档 的存储方式,操作起来比较简单和容易,支持“无模式”的数据建模,可以存储比较复杂的数据类型,是一…...
解锁前端Vue3宝藏级资料 第五章 Vue 组件应用 4 ( provide 和 inject )
5.5 provide 和 inject 前面的知识告诉我们vue中组件之间传递值需要使用props来完成,但是props也有一定局限性。这个时候在vue3中还有另外的解决方法。那就是使用 provide 和 inject 允许父组件将数据传递给所有后代组件,而不管组件层次结构有多深。你要…...
【List篇】LinkedList 详解
目录 成员变量属性构造方法add(), 插入节点方法remove(), 删除元素方法set(), 修改节点元素方法get(), 取元素方法ArrayList 与 LinkedList的区别Java中的LinkedList是一种实现了List接口的 双向链表数据结构。链表是由一系列 节点(Node)组成的,每个节点包含了指向 上一个…...
推动统一供应链“度量衡”,上汽大通突破传统拥抱SaaS生态
中国汽车市场规模已连续14年位居世界第一,目前占世界汽车份额31%。近年来,物联网、人工智能、电池等技术的快速发展,也为中国从汽车大国逐步迈向汽车强国注入巨大动力。在新一轮的汽车产业变革中,构建一个更智能、更高效协同的供应…...
蓝牙核心规范(V5.4)10.9-BLE 入门笔记之GAP
1.概述 蓝牙核心规范的通用访问配置文件(GAP)部分定义了与设备发现和在两个设备之间建立连接有关的过程。如何执行数据的基本无连接通信、如何使用周期性广播(参见 PADVB-LE Periodic Advertising Broadcast)以及如何设置等时通信(参见 LE BIS和LE CIS - Isochronous Com…...
nginx 配置 ssl
1.1 Nginx如果未开启SSL模块,配置Https时提示错误 原因也很简单,nginx缺少http_ssl_module模块,编译安装的时候带上--with-http_ssl_module配置就行了,但是现在的情况是我的nginx已经安装过了,怎么添加模块࿰…...
家居设计软件Live Home 3D Pro mac中文版特点介绍
Live Home 3D Pro mac是一款专业的3D家居设计软件,可以帮助用户轻松创建和设计家居平面图和3D模型,并进行渲染和虚拟漫游。 Live Home 3D Pro mac软件特点 1. 界面友好:Live Home 3D Pro的界面友好,操作简单方便…...
OkHttp - 现代应用网络的方式
官网:Overview - OkHttp HTTP is the way modern applications network. It’s how we exchange data & media. Doing HTTP efficiently makes your stuff load faster and saves bandwidth. OkHttp is an HTTP client that’s efficient by default: HTTP/2 s…...
SpringBoot3基础:最简项目示例
说明 本文建立一个最基本的SpringBoot3项目,依赖项仅包含 spring-web(SpringMVC)。 备注:SpringBoot3需要JDK17支持,配置方法参考: SpringBoot3项目中配置JDK17 项目结构图示 POM <?xml version&qu…...
flex:1详解,以及flex:1和flex:auto的区别
什么是flex:1? 在css中,我们经常可以看到这样的写法: .box {display: flex; }.item {flex: 1; }这里的flex:1相当于flex: 1 1 0%,它是一个简写属性,表示项目(flex item)在弹性容器…...
在VMware虚拟机中固定CentOS系统ip(使用桥接模式)
目录 一、前置说明二、前置准备2.1、切换虚拟机网络为桥接模式2.2、查看本机网络信息 三、配置CentOS系统IP3.1、进入系统输入ip addr 查看本机网络配置名称3.2、查看网络配置目录,网络配置文件名称3.3、修改网络配置文件 ifcfg-ens33 固定IP3.4、重启网络 一、前置…...
怎样才能让百度搜索到自己的博客?--九五小庞
怎么把自己的博客推荐到百度、Google等主要搜索引擎? 如果不把你的博客提交到各大搜索引擎中,它们一般是不会收录你的博客的,你可以先尝试一下看看能不能在百度搜到你的博客吧。 如果搜不到的话说明你的博客还没有被百度收录,那么…...
【学习笔记】多模态综述
多模态综述 前言1. CLIP & ViLT2. ALBEF3. VLMO4. BLIP5. CoCa6. BeiTv3总结参考链接 前言 本篇学习笔记虽然是多模态综述,本质上是对ViLT后多模态模型的总结,时间线为2021年至2022年,在这两年,多模态领域的模型也是卷的飞起…...
MLAgents (0) Unity 安装及运行
1、下载ML-Agents 下载地址 GitHub - Unity-Technologies/ml-agents: The Unity Machine Learning Agents Toolkit (ML-Agents) is an open-source project that enables games and simulations to serve as environments for training intelligent agents using deep reinfo…...
typename关键字详解(消除歧义)
typename关键字详解 文章目录 typename关键字详解定义用法1.和class同义,用于引入泛型编程中所用到的模板参数2.用来消除歧义,告诉编译器后面的是类型名而不是变量名 定义 typename相当于泛型编程中class的同义关键字,用来指出模板类型所依赖…...
设计模式_解释器模式
解释器模式 案例 角色 1 解释器基类 (BaseInterpreter) 2 具体解释器1 2 3... (Interperter 1 2 3 ) 3 内容 (Context) 4 用户 (user) 流程 (上下文) ---- 传…...
【算法基础】数学知识
质数 质数的判定 866. 试除法判定质数 - AcWing题库 时间复杂度是logN #include<bits/stdc.h> using namespace std; int n; bool isprime(int x) {if(x<2) return false;for(int i2;i<x/i;i){if(x%i0) return false;}return true; } signed main() {cin>&g…...
PDCA循环
目录 1.认识PDCA: 2.PDCA循环的经典案例 3.PDCA的四个阶段和八个步骤 4.PDCA循环的优缺点: 5.案例 6.其他作用 1.认识PDCA: PDCA循环最早由美国质量统计控制之父Shewhat(休哈特)提出的PDS(Plan Do Se…...
Redis 缓存雪崩、缓存穿透、缓存击穿
Redis 是一种常用的内存缓存工具,但在某些情况下,它可能会遭受缓存雪崩、缓存穿透和缓存击穿等问题。下面是一些预防这些问题的建议: 1、缓存雪崩 缓存雪崩指的是在某个时间点上,大量的缓存数据同时失效或过期,导致大…...
Android Media3 ExoPlayer 开启缓存功能
ExoPlayer 开启播放缓存功能,在下次加载已经播放过的网络资源的时候,可以直接从本地缓存加载,实现为用户节省流量和提升加载效率的作用。 方法一:采用 ExoPlayer 缓存策略 第 1 步:实现 Exoplayer 参考 Exoplayer 官…...
MyBatis注解开发
MyBatis常用注解 注解对应XML说明Insert< insert>新增SQLUpdate< update>更新SQLDelete< delete>删除SQLSelect< select>查询SQLParam–参数映射Results< resultMap>结果映射Result< id>< result>字段映射 开发流程: 1…...
C# Onnx Yolov8 Cls 分类
效果 项目 代码 using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System…...
Fiddler常用的快键键
Fiddler有很多常用的快捷键,这些快捷键可以帮助你更快速地完成任务。以下是一些常用的快捷键: F12:启动/停止抓包。 CtrlR:打开FiddlerScript窗口。 CtrlH:切换到 Inspector 页签的 Header 视图。 CtrlT:切…...
【Linux】生产消费模型 + 线程池
文章目录 📖 前言1. 生产消费模型2. 阻塞队列2.1 成员变量:2.2 入队(push)和出队(pop):2.3 封装与测试运行:2.3 - 1 对代码进一步封装2.3 - 2 分配运算任务2.3 - 3 测试与运行 3. 循环阻塞队列3.1 POSIX信号量:3.1 - 1…...
基于springboot+vue的爱心助农网站(前后端分离)
博主主页:猫头鹰源码 博主简介:Java领域优质创作者、CSDN博客专家、公司架构师、全网粉丝5万、专注Java技术领域和毕业设计项目实战 主要内容:毕业设计(Javaweb项目|小程序等)、简历模板、学习资料、面试题库、技术咨询 文末联系获取 项目介绍…...
“华为杯”研究生数学建模竞赛2019年-【华为杯】D题:汽车行驶工况构建(附获奖论文和MATLAB代码实现)
目录 摘 要: 1. 问题重述 2. 模型假设 2.1 题目对模型给出的假设...
v-cloak的作用和原理
1、作用 v-cloak 指令常用在插值表达式的标签中,用于解决当网络加载很慢或者频繁渲染页面时,页面显示出源代码的情况。 所以为了提高用户的体验性,使用指令 v-cloak,搭配着 CSS 一起使用,在加载时隐藏挂载内容&#x…...
pip pip3安装库时都指向python2的库
当在python3的环境下使用pip3安装库时,发现居然都指向了python2的库 pip -V pip3 -V安装命令更改为: python3 -m pip install <package>...
和逸云 RK3229 如何进入maskrom强刷模式
图中红圈两个点短接以后插usb,就可以进入maskrom模式强刷...
上海企业网站/竞价sem培训
由于在使用Django的过程当中使用到了Nginx和Fastfds,本文记录一下过程: fastfds架构: 一:安装fastdfs依赖包: 解压缩libfastcommon-master.zip进入到libfastcommon-master的目录中执行 ./make.sh执行 sudo ./make.sh i…...
广州做英文网站的公司/手机免费建站app
NSNotFound 定义一个值,用于指示请求项找不到或不存在。Defines a value that indicates that an item requested couldn’t be found or doesn’t exist. enum { NSNotFound NSIntegerMax}; NSEnumerationOptions 块枚举操作的选项。Options for Block enumeratio…...
惠州网站设计培训/网址收录查询
1、多态定义多态指的是一类事物的多种形态比如动物有多种形态:人,狗,猪importabcclass Animal(metaclassabc.ABCMeta):abc.abstractmethoddeftalk(self):passclassPeople(Animal):deftalk(self):print(say people)classDog(Animal):deftalk(s…...
个人能建什么样的网站/刷百度关键词排名优化
今天的 Session 将主要将四件事情: 你可能已经在 App 中使用的一些核心技巧,我们会讨论如何使用它们构建更好的 App讨论如何通过 UIKit 和其他一些 API 构建更好的用户界面展示你可以将哪些新功能集成到你的应用中应用如何通过新的系统 extension 来拓展…...
上海专门做网站的公司/win10优化工具下载
本来想的是每个类型的按钮分开写,但是分开写每篇字数太少。今天对按钮使用进行一个简单的总结,希望可以对大家有所帮助!什么叫按钮组呢?简单来说按钮组就是指两个活两个以上的按钮排布在一起。从按钮组的样式上我们可以看出常见的…...
金昌市住房和城乡建设局网站/英文seo
今日主要简单了解面向对象思想以及类的介绍 万物皆对象 面向对象主要有三大特性 一、封装 二、继承 三、多态 将现实生活中实际存在的东西,抽象的提取成程序中的类,对类进行统一的操作,都是以类为基本单元。 面向对象使用的好处 1、使程序结构…...