cpp虚函数机制之一

最近面试,被吊打了cpp的虚函数机制,所以搞一通记录一些问题。

一、基础知识

首先,cpp中类模型根据是成员是否是静态,分出了static成员和non-static成员,而成员函数作为类成员的一种,同样可以这样分类,但它还有虚函数,所以成员函数除了静态和非静态,虚函数是独一档。

类的内存模型中,每一个类对象,内存中只有非静态成员和因为内存对齐的多余内存,如果声明了虚函数,那就会在类中添加一个虚指针,这些个内存的分配都会在类对象进行实际声明定义的时候才会进行。

class Base{
    public:
        int pub_a;
    private:
        int pri_a;
    protected:
        int pro_a;
};

int main(){
    Base a;
    ...
}

如上是一个简单基类,只声明了三种访问等级的类成员,针对这个基类,在main中进行了Base对象a,然后检查一下内存信息:

g++ test.cpp -o test -g
gdb test

gdb调试:

(gdb) b 14
Breakpoint 1 at 0x40155d: file .\test.cpp, line 14.
(gdb) r
Starting program: D:\Desktop\test\test.exe
[New Thread 13424.0x5234]
[New Thread 13424.0x6124]
[New Thread 13424.0x3a78]
[New Thread 13424.0x6f64]

Thread 1 hit Breakpoint 1, main () at .\test.cpp:14
14              std::cout << "action" << std::endl;
(gdb) print a
$1 = {pub_a = 0, pri_a = 16, pro_a = 0}
(gdb) print sizeof a
$2 = 12
(gdb) x /4x &a
0x61fe14:       0x00000000      0x00000010      0x00000000      0x00621960
(gdb)

如上,Base对象a的三个成员,虽然它们的值为0或者16,但实际上它是没有进行默认初始化的,这个只是随机值,重新运行查看会有新的值。然后检查了一下对象a的构造,g++检查一下对象布局:

g++ -fdump-lang-class -c test.cpp

命令记录了test.cpp中的内存布局,在生成的.class文件可以查看其布局:

Class Base
   size=12 align=4
   base size=12 base align=4
Base (0x0x7382120) 0

和上面gdb调试的结果一样,Base对象成员分配了12字节大小的内存,因为三个成员都是int嘛,所以内存对齐直接就4乘以3,得到的结果就是其内存大小了,而内存对齐的参数也在上面标示出来了,是4。然后添加一下虚函数,看看布局是怎么样的:

class Base{
    public:
        int pub_a;
		virtual int pub_fun(){return pub_a;}
    private:
        int pri_a;
    protected:
        int pro_a;
};

int main() {
	Base a;
	a.pub_fun();
...
}

对象声明之前

对象声明以后

上面是打断点到a进行声明的地方和a进行声明后的信息,前后查看a的信息,可以发现,a中包括虚指针在内的成员都会进行默认的初始化,而a的int成员因为它类型特性,初始化是随意的,但指针默认会初始化为0x8,只有当对象被实际构造,才会有虚函数表的值。检查一下虚函数表的信息:

(gdb) x /16x 0x004044f0-32
0x4044d0 <_ZTS4Base>:   0x73614234      0x00000065      0x00000000      0x00000000
0x4044e0 <_ZTV4Base>:   0x00000000      0x00000000      0x004044c0      0x00000000
0x4044f0 <_ZTV4Base+16>:        0x00402ce0      0x00000000      0x00000000      0x00000000
0x404500 <_ZTV4Base+32>:        0x3a434347      0x38782820      0x34365f36      0x736f702d
(gdb) quit
A debugging session is active.

        Inferior 1 [process 22144] will be killed.

Quit anyway? (y or n) y
PS D:\Desktop\test> c++filt.exe _ZTV4Base
vtable for Base
PS D:\Desktop\test> c++filt.exe _ZTV4Base+16
_ZTV4Base+16

如上,使用c++filt来确认这部分信息,的确是Base的虚函数表。从这里开始,布局这块是比较清楚了。为了让布局更加明了,g++命令查看一下其布局:

g++ -fdump-lang-class -c test.cpp

查看新生成的.class文件,查找Base类的信息如下:

Vtable for Base
Base::_ZTV4Base: 3 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI4Base)
16    (int (*)(...))Base::pub_fun

Class Base
   size=24 align=8
   base size=20 base align=8
Base (0x0x67cc120) 0
    vptr=((& Base::_ZTV4Base) + 16)

如上,可以清楚地看出一个Base类对象,往往是这么一份结构,单纯查看其布局,其实主要是考虑是否添加虚指针的内存布局而已,而实际的虚函数会在虚指针指向的vtable中保存实际地址。结构如下:

a对象的布局

那么这么一份vtable会在什么时候进行初始化呢?在main中把a的声明进行删除,只保留Base这个类的声明而不声明其对象,发现它的内存布局依然保留着vtable,这是否能说明它是不跟随类的实际对象一起进行初始化?暂时留着这个疑惑,看后续是否解惑。

在上面可以看到的一个信息,就是type_info,这个是什么呢?这个是类型信息,因为类是可以进行继承的,而对于这些个父子类型,如何进行确定呢?那就需要附加信息了,type_info就是这个类型的身份证明。在虚表表头,它会有特别的16字节,这里面的后8个字节存储着type_info表的地址。来重新check一下信息:

# 获取vtable的信息
(gdb) x /16x 0x4044f0-32
0x4044d0 <_ZTS4Base>:   0x73614234      0x00000065      0x00000000      0x00000000
0x4044e0 <_ZTV4Base>:   0x00000000      0x00000000      0x004044c0      0x00000000
0x4044f0 <_ZTV4Base+16>:        0x00402ce0      0x00000000      0x00000000      0x00000000
0x404500 <_ZTV4Base+32>:        0x3a434347      0x38782820      0x34365f36      0x736f702d
(gdb)

# c++filt来查看其信息
PS C:\Users\penta> c++filt.exe _ZTV4Base
vtable for Base
PS C:\Users\penta> c++filt.exe _ZTS4Base
typeinfo name for Base

2.1 初始化时期

从上面可以了解到,一个类的实例对象,往往是由其非静态成员和虚指针组成(如果有虚函数),这部分内存遵照内存对齐的规则来进行内存分配,以期达到最大的CPU数据读取效果,也是对硬件特性的妥协;除此以外,它的静态成员、成员函数、虚函数、虚函数表等,会独立地占据一部分内存,也就是全局数据区。

一个类的实例的构造,往往和构造函数相关,这个实例被初始化的时候,该类同名的没有任何返回类型的特殊函数,会负责对其类成员进行构造。类的成员因为有静态成员、非静态成员,以及独特的虚函数,它们在什么时候进行初始化?

静态成员

就静态数据来说,一般需要在程序运行的时候就存在,而函数往往是在程序运行的时候被调用,所以函数不能在任何函数内部进行空间分配和初始化。似乎很早之前,存在类内部对静态成员进行初始化,但现在不行了,在内部给静态成员初始化必须是常量,或者在类外进行初始化。如下:

class Base {
	static int cnt;
};

int Base::cnt = 10;

int main() {
	std::cout << "action" << std::endl;
	return 0;
}

如上进行类外的静态成员初始化,无论是gdb调试还是在vs中进行调试,cnt成员都会在程序运行前初始化完成,而操作静态成员的只能是类的静态成员函数。

非静态成员

非静态成员是类对象持有,在进行声明的时候就进行初始化,使用类构造函数进行,当一个类没有声明构造函数的时候,编译器会自行添加一个构造函数。针对类的构造函数,c++11提供了那么几个关键字进行辅助:

  • default,添加在构造函数后,表明这是这个类的默认构造函数,这样就可以防止编译器自己进行默认构造函数的声明;
  • explict,一样使用在构造函数的修饰上,主要表明这个构造函数不能在隐式转换中使用,一般是用在只有一个参数的默认构造;
  • delete,这个是用来告诉编译器,这一种函数不需要自动生成的意思,另外也表明不使用这类构造函数进行初始化

虚函数

重述一下,虚函数机制在cpp中,主要是在类对象中维持一个虚指针vptr,另外有一个虚表vtable,内部是地址元素,分别是指向实际定义的虚函数和type_info表,而vptr指向vtable中第一个虚函数的地址。可以知道的是,在类对象被声明之前,类的虚表并不会进行内存分配,如果类对象被声明了(嗯,好像静态成员被访问了也可以),那它的虚指针就会初始化为虚表的地址。

2.2 关于构造函数

构造函数花样多,主要有默认构造函数:

class Base{
public:
    Base();
};

就像上面那样,空函数,啥也不干,也算一个默认构造函数。普通构造函数:

class A{
public:
    A(int x){
		a=x;
	}
	int a;
};

A a(1);

class B{
public:
    B(int x):a(x){;}
	int a;
};

B b(3);

上面有两种不同的构造函数实现,后者用上了初始化列表的方式进行。在cpp中,对于成员是另一个类的类来说,使用初始化列表可以少调用一次调用默认构造的过程,算是性能要求;而针对const成员和引用成员,此类成员的初始化必须放初始化列表中。如下:

class A {
public:
	A(int x):a(x) {}
	const int a;
};

class B {
public:
	B(int x) :a(x) {}
	int& a;
};

class C {
public:
	C(int x) :a(x) {}
	const int& a;
};

A a(1);
B b(2);
C c(3);

然后是拷贝构造函数,使用同类对象来进行初始化:

class D {
public:
	D(int x) :a(x) {}
	D(const D& d):a(d.a){}
	const int& a;
};

D d1(1);
D d2(d1);

最后是移动构造函数:

class D {
public:
	D(int x) :a(x) {}
	D(D&& d) noexcept :a(d.a){}
	int& a;
};

D d1(1);
// 移动构造使用
D d2 = std::move(d1);

除此以外还有委托构造和类型转换构造,后者很好理解,就比如string的使用,可以传入一个常量字符串来进行初始化;前者则是涉及了继承,主要是派生类在构造函数中使用基类的构造函数来对成员进行初始化的行为:

class A {
public:
	A(int x) :a(x) {}
	int a;
};

class B :A {
public:
	B(int x, int y) :A(x), b(y) {}
	int b;
};

构造函数是在类对象进行声明的时候自动调用,而析构函数则是类对象的生命周期结束,然后自动调用。

2.3 继承

前面有简单介绍过继承的概念,所以这里继续深入,首先,继承关系中,被继承者是基类,继承自基类的是派生类,一个派生类可以有多个基类,但一个基类只能有一个直接派生类:

class Base {
public:
	int a;
};

class Derived_A :public Base {
	int b;
};

class Derived_B :public Base {
	int b;
};

Base base;
Derived_A a;
Derived_B b;

查看一下布局:

(gdb) p base
$1 = {a = 0}
(gdb) p a
$2 = {<Base> = {a = 0}, b = 16}
(gdb) p b
$3 = {<Base> = {a = 0}, b = 6429088}
(gdb) p sizeof base
$4 = 4
(gdb) p sizeof a
$5 = 8
(gdb) p sizeof b
$6 = 8
(gdb)


Class Base
   size=4 align=4
   base size=4 base align=4
Base (0x0x673c120) 0

Class Derived_A
   size=8 align=4
   base size=8 base align=4
Derived_A (0x0x66e8e38) 0
  Base (0x0x673c180) 0

Class Derived_B
   size=8 align=4
   base size=8 base align=4
Derived_B (0x0x6757000) 0
  Base (0x0x673c1e0) 0

如上,看得出来,Derived_A和Derived_B都继承了Base的a成员,并进行自己成员的拓展,所以就类对象的布局来说,也就是内存对齐添加了成员。那么引入虚函数以后呢?

class Base {
public:
	int a;
	virtual int back(){return a;}
};

class Derived_A :public Base {
	int b;
	int back(){return b;}
};

class Derived_B :public Base {
	int b;
	int back(){return b;}
};

如上进行,两个派生类在基类的基础上进行继承并重写了虚函数,下面使用clang也就是llvm编译器来进行结构展示,执行命令clang -Xclang -fdump-record-layouts -c test.cpp > layout查看内存布局:

*** Dumping AST Record Layout
         0 | class Base
         0 |   (Base vftable pointer)
         8 |   int a
           | [sizeof=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class Derived_A
         0 |   class Base (primary base)
         0 |     (Base vftable pointer)
         8 |     int a
        16 |   int b
           | [sizeof=24, align=8,
           |  nvsize=24, nvalign=8]

*** Dumping AST Record Layout
         0 | class Derived_B
         0 |   class Base (primary base)
         0 |     (Base vftable pointer)
         8 |     int a
        16 |   int b
           | [sizeof=24, align=8,
           |  nvsize=24, nvalign=8]

执行命令clang -Xclang -fdump-vtable-layouts -c test.cpp > layout查看虚表布局:

VFTable for 'Base' (2 entries).
   0 | Base RTTI
   1 | int Base::back()

VFTable indices for 'Base' (1 entry).
   0 | int Base::back()

VFTable for 'Base' in 'Derived_A' (2 entries).
   0 | Derived_A RTTI
   1 | int Derived_A::back()

VFTable indices for 'Derived_A' (1 entry).
   0 | int Derived_A::back()

VFTable for 'Base' in 'Derived_B' (2 entries).
   0 | Derived_B RTTI
   1 | int Derived_B::back()

VFTable indices for 'Derived_B' (1 entry).
   0 | int Derived_B::back()

如上,简单关注vftable信息即可,一个虚表包含RTTI指针和后面的类里面所有的虚函数,每一个虚函数表对应一个虚函数。当派生类没有对基类虚函数进行重写的时候,虚函数表中指向的是基类的虚函数,只有进行了重写才会把派生类虚表的对应地址进行覆盖。

抽象基类

在面向对象中,强大的继承机制,造就了一个又一个的庞大“家族”,它们之间,相互继承,相互嵌套,衍生出功能更多,体型更为庞大的派生类。在这种机制中,会有非常独特的基类存在,它的存在,只是为了继承,它并不想要别人声明这样的对象,这种基类就是抽象基类。

如何实现抽象基类?在这个基类内部声明一个纯虚函数就行,或者继承一个带有纯虚函数的类但并不对它进行重写覆盖。虚函数是使用virtual进行标识的成员函数,那么什么是纯虚函数?纯虚函数就是正常虚函数给后面添加=0标识即可。一般来说,纯虚函数和这个类都是不准备拿来用的,所以一般情况下,纯虚函数也不会进行定义,但要提供定义也可以,必须在类外部进行定义。

class Base {
	virtual void f() = 0;
};

void Base::f() {
	std::cout << "Nothing" << std::endl;
}

大概就上面的样式,对于这种只是拿来继承的基类,它的最大价值就是虚函数了,通过声明这么一个抽象基类的指针,然后指向派生类成员,派生类成员都可以对抽象基类的虚函数进行重写覆盖,然后这个指针调用该函数,它本来指向的对象是什么派生类,所调用虚函数就表现什么特性。

2.3.1 菱形继承问题

在继承中,一个基类可以衍生出许多派生类,派生类之间也可以相互继承派生,菱形继承就是两个拥有同样的基类的派生类,共同派生出一个派生类,这种就是菱形继承,那么它有什么问题?

class Base {
public:
	int a;
};

class Derived_A :Base {};

class Derived_B :Base {};

class Derived_C : Derived_A , Derived_B {};

Derived_C c;
// 问题所在
c.a = 10;

当上面的代码进行编译的时候,问题就出现了:

PS D:\Desktop\test> g++ test.cpp -o test
test.cpp: In function 'int main()':
test.cpp:17:4: error: request for member 'a' is ambiguous
  c.a = 10;
    ^
test.cpp:5:6: note: candidates are: 'int Base::a'
  int a;
      ^
test.cpp:5:6: note:                 'int Base::a'
PS D:\Desktop\test>

ambiguous,模糊两可,表示a的出处并不明了,为何?因为上面的继承,会在两个派生类中都保留一份基类的a成员,这样的两个派生类再派生出的Derived_C派生类,它就有着两份基类信息:

*** Dumping AST Record Layout
         0 | class Base
         0 |   int a
           | [sizeof=4, align=4,
           |  nvsize=4, nvalign=4]

*** Dumping AST Record Layout
         0 | class Derived_A
         0 |   class Base (base)
         0 |     int a
           | [sizeof=4, align=4,
           |  nvsize=4, nvalign=4]

*** Dumping AST Record Layout
         0 | class Derived_B
         0 |   class Base (base)
         0 |     int a
           | [sizeof=4, align=4,
           |  nvsize=4, nvalign=4]

*** Dumping AST Record Layout
         0 | class Derived_C
         0 |   class Derived_A (base)
         0 |     class Base (base)
         0 |       int a
         4 |   class Derived_B (base)
         4 |     class Base (base)
         4 |       int a
           | [sizeof=8, align=4,
           |  nvsize=8, nvalign=4]

如上,Derived_C的内存布局中,很明显的看出有着来自两个派生类中的Base的成员,所以调用的时候就出现了ambiguous两义性。针对这种方案的解决办法就是使用虚继承:

class Base {
public:
	int a;
};

class Derived_A :virtual public Base {};

class Derived_B :virtual public Base {};

class Derived_C : public Derived_A ,public Derived_B {};

Derived_C c;
c.a = 10;

这时候编译就没问题了,运行ok。那么为什么给两个直接派生类修改继承方式为虚继承能解决问题呢?再来关注一下其布局:

*** Dumping AST Record Layout
         0 | class Base
         0 |   int a
           | [sizeof=4, align=4,
           |  nvsize=4, nvalign=4]

*** Dumping AST Record Layout
         0 | class Derived_A
         0 |   (Derived_A vbtable pointer)
         8 |   class Base (virtual base)
         8 |     int a
           | [sizeof=16, align=8,
           |  nvsize=8, nvalign=8]

*** Dumping AST Record Layout
         0 | class Derived_B
         0 |   (Derived_B vbtable pointer)
         8 |   class Base (virtual base)
         8 |     int a
           | [sizeof=16, align=8,
           |  nvsize=8, nvalign=8]

*** Dumping AST Record Layout
         0 | class Derived_C
         0 |   class Derived_A (base)
         0 |     (Derived_A vbtable pointer)
         8 |   class Derived_B (base)
         8 |     (Derived_B vbtable pointer)
        16 |   class Base (virtual base)
        16 |     int a
           | [sizeof=24, align=8,
           |  nvsize=16, nvalign=8]

可以看到,Derived_A和Derived_B对Base进行了虚继承以后,开辟了一个指针内存,专门指向基类,所以最后的派生类Derived_C继承了两个基类,也继承了两份基类的虚指针,这样最后得到的其实是一份基类,所以也就没有了二义性。

三、一些面试问题

构造函数可以是虚函数吗?为什么?,针对一个虚函数,只需要部分信息,就可以进行调用,也就是函数接口而不是具体类型,而构建一个对象,则需要具体的类型信息,构造函数做的就是这个工作,所以不能。

析构函数为什么可以是虚函数,使用两个例子进行说明:

class Base {
public:
	Base(int x):a(x){std::cout<<"Base构造\n";}
	~Base(){std::cout<<"Base销毁\n";}
	int a;
};

class Derived_A :public Base {
public:
	Derived_A(int x, int y):Base(x), derived_a(y){
		std::cout << "Derived_A构造\n";
	}
	~Derived_A(){std::cout << "Derived_A销毁\n";}
	int derived_a;
};

class Derived_B : public Derived_A {
public:
	Derived_B(int x, int y):Derived_A(x,y){std::cout<<"Derived_B构造\n";}
	~Derived_B(){std::cout << "Derived_B销毁\n";}
};

int main() {
	Base base(1);
	std::cout << std::endl;
	Derived_A a(1,2);
	std::cout << std::endl;
	Derived_B b(2,3);
	std::cout << "action" << std::endl;
	return 0;
}

程序跑起来以后,正常运行如下:

PS D:\Desktop\test> ./test
Base构造

Base构造
Derived_A构造

Base构造
Derived_A构造
Derived_B构造
action
Derived_B销毁
Derived_A销毁
Base销毁
Derived_A销毁
Base销毁
Base销毁

这样就是一个多重继承中,构造函数的调用顺序和析构函数的调用顺序。构造函数正向构造,从基类到派生类;析构函数反向销毁,从派生类到基类逐层回溯。然后是下面的使用例子,声明一个类的指针,指向这个类的派生类:

Derived_A *p = new Derived_B(1,2);
delete p;

运行结果就成这样了:

PS D:\Desktop\test> ./test
Base构造
Derived_A构造
Derived_B构造
action
Derived_A销毁
Base销毁

看得出构造函数正常调用,但析构函数没有正常调用,关于Derived_B中的析构函数没有被调用,但因为Derived_B没有实际成员,所以没有出现问题,但实际上,这样没有正常资源释放的情况是会造成内存泄露的。那么这种泄露问题如何解决?就是用的虚析构:

class Base {
public:
	...
	virtual ~Base(){std::cout<<"Base销毁\n";}
	...
};
PS D:\Desktop\test> ./test
Base构造
Derived_A构造
Derived_B构造
action
Derived_B销毁
Derived_A销毁
Base销毁
PS D:\Desktop\test>

ok,问题解决,那原理是什么呢?在声明基类为虚析构前后,使用clang查看一下虚表布局;

VFTable for 'Base' (2 entries).
   0 | Base RTTI
   1 | Base::~Base() [scalar deleting]

VFTable indices for 'Base' (1 entry).
   0 | Base::~Base() [scalar deleting]

VFTable for 'Base' in 'Derived_A' (2 entries).
   0 | Derived_A RTTI
   1 | Derived_A::~Derived_A() [scalar deleting]

VFTable indices for 'Derived_A' (1 entry).
   0 | Derived_A::~Derived_A() [scalar deleting]

VFTable for 'Base' in 'Derived_A' in 'Derived_B' (2 entries).
   0 | Derived_B RTTI
   1 | Derived_B::~Derived_B() [scalar deleting]

VFTable indices for 'Derived_B' (1 entry).
   0 | Derived_B::~Derived_B() [scalar deleting]

在声明基类析构为虚函数之前,类Base没有虚函数,所以没有虚表结构,而声明了虚析构以后,基类创建虚表,同时其后派生类也进行虚表的创建,通过虚表,可以调用基类的析构函数,从而正确释放资源,所以多重继承中,虚析构很重要。总的来说,针对含有虚函数的类,它们的继承间如果有多态概念的调用,析构函数还是声明为虚函数才好进行资源释放。

虚函数调用顺序,获取对象中虚指针的值,寻址到虚表,然后调用虚函数。

构造函数初始化顺序,如果是派生类,会按照当前基类声明,进行逐层初始化;如果存在菱形继承,那就从左到右遍历,主要按照基类声明的深度优化;按照类中成员声明顺序,进行非静态成员的初始化,初始化完毕后,执行构造函数体,比如上面的构造函数中的输出语句。

小问题

在测试的时候,使用clang来进行测试源文件的debug信息的添加,结果后面gdb进行调试的时候,案例不一致,把可执行文件删去后,重新g++ test.cpp -o test -g的时候,还是不行,这时候就需要把所有中间文件给删掉,再重新编译就可以了。

更多文章可以查看

posted @ 2023-06-08 10:51  夏目&贵志  阅读(90)  评论(0编辑  收藏  举报