类与数据抽象

10 篇文章 1 订阅

有关类的知识

类和对象的概念

类:类是现实世界在计算机中的反映,它将数据和对这些数据的操作封装在一起(并没有开空间)
  具有相同特性(数据元素)和行为(功能)的对象的抽象就是类。
对象:类的实例(占有实际的空间)
类是对象的概念,是对象的模板,而对象是类的具体实例。
在这里插入图片描述

类的对象的大小

一个类的实例化对象所占空间的大小? 注意不要说类的大小,是类的对象的大小。 
首先,类的大小是什么?确切的说,类只是一个类型的定义,没有大小可言,用sizeof运算符对一个类型名操作,得到的是具有该类型实体的大小。
(后续类大小都指类的对象的大小)
  • 与类大小有关的因素:普通成员变量,虚函数,继承(单一继承,多重继承,重复继承,虚拟继承)
  • 与类大小无关的因素:静态成员变量,静态成员函数及普通成员函数
    类的静态成员与函数不占类的大小。

1、 与虚函数相关的原因:虚函数本身不占大小,但一个类中若有虚函数(不论是自己的虚函数,还是继承而来的),那么类中就有一个成员变量:虚函数指针,这个指针指向一个虚函数表,虚函数表的第一项是类的typeinfo,之后的项为此类的所有虚函数的地址。假设经过成员对齐后的类的大小为size个字节。那么类的sizeof大小可以这么计算:size + 4 *(虚函数指针的个数n)。代码中:
class Base1{
virtual void fun1(){}
virtual void fun11(){}
public:
virtual ~Base1();
};
class Base2{
virtual void fun2(){}
};
class DerivedFromTwo: public Base1, public Base2
{
 virtual void fun3(){}
};
DerivedFromTwo继承自2个分支,所以有2个虚函数指针,所以sizeof大小为0 + 4 * 2 = 8。带有虚函数的类的sizeof大小,实际上和虚函数的个数不相关,相关的是虚函数指针。

2、为什么静态成员变量,静态成员函数和普通成员函数是无关因素?
a:(对于成员函数来说,函数名本身就指明了函数是属于哪个类的(连参数类型都有),因此,编译器在编译代码时,可以直接调用该类的成员函数,而不需要类本身提供任何信息。)类的成员函数放在公共代码区,所有该类的对象共享这些成员函数,每个对象的大小为类内成员变量的大小之和,遵循内存对齐原则。
b: 对于静态成员变量来说, 静态成员变量占用全局的内存。和全局变量分配的内存在同一个区域里面,而 sizeof() 计算的是栈区的大小。

3、关于空类
class student{
};
对于空类来说,即使它里面没有任何成员,它的大小也不会为0。因为空类也是可以实例化的。
例如:对于上面的空类,以下实例化时没有问题的 student std; 那么既然可以实例化,实例化之后必然在内存中占据位置,所以编译器将其大小优化为 1 字节。

当一个构造函数被调用时,它做的首要的事情之中的一个是初始化它的VPTR,如果有虚函数或者虚类的话。
虚函数的实现的基本原理
C++中虚函数继承类的内存占用大小计算
C/C++杂记:深入理解数据成员指针、函数成员指针

普通类的大小(遵循内存对齐)

具体查看【基础知识–内存/字节对齐】因为不同的声明顺序会造成空间差距,因此我们在自己声明类时,一定要注意到内存对齐问题,优化类的对象空间分布。

进行内存对齐的原因:为了提高程序的性能,方便cpu访问内存,处理器并不是一个字节一个字节来访问内存,一般是4个字节或8个字节。
详细:内存对齐底层的原因是内存的IO是以块为单位进行的。
尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度.
现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。
假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的联系四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器.这需要做很多工作。

内存对齐的规则
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4),可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。
有效对其值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位。
了解了上面的概念后,我们现在可以来看看内存对齐需要遵循的规则:
(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。
(3) 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

class base1
{
private:
    char a;
    int b;
    double c;
};
class base2
{
private:
    char a;
    double b;
    int c;
};

虽然上述两个类成员变量都是一个char,一个int,一个double,但是不同的声明顺序,会导致不同的内存构造模型,对于base1,base2,其成员排列:
base1:
在这里插入图片描述
base2:
在这里插入图片描述
base 1类对象的大小为16字节,而base 2类对象的大小为24字节,造成了8字节的空间差距。

类的声明与定义

  • 声明:class B;
  • 定义:class B{...};

类的三大特性

①封装:函数的封装是一种形式,隐藏对象的属性和实现细节(函数内部),仅仅对外提供函数的接口和对象进行交互。类的访问限定符可以协助其完成封装。
②继承:从已有的类中派生出新的类,新的类能吸收已有类的数据属性和行为,并能扩展新的能力。
③多态:按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同表现方式即为多态。同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果,这就是多态性。

类通过私有成员体现“封装”,通过直接继承或者组合体现“继承”,通过虚函数和动态绑定(dynamic binding)体现“多态”。

类的构造顺序

“先看继承后看成员”

  1. 虚基类的构造函数(多个虚基类则按照继承的顺序执行构造函数)。
  2. 对象的vptr被初始化;
  3. 如果有成员初始化列表,将在构造函数体内扩展开来,这必须在vptr被设定之后才做;
  4. 类类型的成员对象的构造函数(按照声明顺序)
  5. 构造函数中执行程序员所提供的代码;

类成员的访问权限

C++通过public、protected、private三个关键字来控制成员变量与成员函数的访问权限,它们分别表示共有的、受保护的、私有的,被称为成员访问限定符

成员访问限定符

  • 在类的内部(定义类的代码内部),无论成员被声明为public、protected还是private,都可以相互访问,没有访问权限的限制。
    在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问public属性的成员,不能访问protected、private属性的成员。

  • protected
     具有protected访问控制级别的成员是半公开的,外界无法直接访问这个控制级别的成员,只能被本类或者子类的成员函数或者友元函数访问。

  • 继承方式

  • public继承
    基类继承的成员在派生类中访问权限不变。

  • protected继承
    基类中继承的public访问权限变成protected,其他不变。

  • private继承
    基类继承的所有访问权限都变为private。

1.public:类的成员可以从类外直接访问
2.private/protected:类的成员不能从类外直接访问
3.类的每个访问限定符可以多次在类中使用,作用域为从该限定符开始到下一个限定符之前/类结束
4.类中如果没有定义限定符,则默认为私有的(private)
5.类的访问限定符体现了类的封装性

友元与继承

就像友元关系不能传递一样,友元关系同样不能继承。
基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。

友元的用法与功能

采用类的机制后实现了数据的隐藏与封装,类的数据成员一般定义为私有成员,成员函数一般定义为公有的,以此提供类与外界间的通信接口。但是,有时需要定义一些函数,这些函数不是类的一部分,但又需要频繁地访问类的数据成员,这时可以将这些函数定义为该函数的友元函数。除了友元函数外,还有友元类,两者统称为友元。友元的作用是提高了程序的运行效率(即减少了类型检查和安全性检查等都需要时间开销),但它破坏了类的封装性和隐藏性,使得非成员函数可以访问类的私有成员。

友元函数

友元函数是可以直接访问类的私有成员的非成员函数。它是定义在类外的普通函数,它不属于任何类,但需要在类的定义中加以声明,声明时只需在友元的名称前加上关键字friend,其格式如下:friend 类型 函数名(形式参数);

  • 友元函数的声明可以放在类的私有部分,也可以放在公有部分,它们是没有区别的,都说明是该类的一个友元函数。
  • 一个函数可以是多个类的友元函数,只需要在各个类中分别声明。
  • 友元函数的调用与一般函数的调用方式和原理一致。

友元类

友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
当希望一个类可以存取另一个类的私有成员时,可以将该类声明为另一类的友元类。定义友元类的语句格式如下: friend class 类名;
其中:friend和class是关键字,类名必须是程序中的一个已定义过的类。同时声明的位置不限只需要在类中即可。
由于在GoodFriend类中有着Building类的一个具体实现的函数声明,故而关于
Building类的声明需要放在GoodFriend类的前面!同时GoodFriend类需要预声明:

class B;
class A{ 
	friend class B;
	...... 
} ;
class B{ ...... } ;
class GoodFriend;//预声明 

class Building
{
    //GoodFriend类为Building的友元类
    friend class GoodFriend;
    
public:
    Building()
    {
        m_SittingRoom = "SittingRoom";
        m_BedRoom = "BedRoom";
    }
public:
    string m_SittingRoom;
private:
    string m_BedRoom;
};

class GoodFriend
{
public:
    Building *building;
    void visit()   //参观函数,访问Building中的属性
    {
        cout<<building->m_SittingRoom<<endl;
        cout<<building->m_BedRoom<<endl;
    }
    GoodFriend()
    {
        building = new Building;
    }
};

int main()
{
	GoodFriend t;
	t.visit() ;
    
    return 0;
}

经过以上说明后,类GoodFriend的所有成员函数都是类Building的友元函数,能存取类Building的所有成员。

使用友元类时注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的声明。

为什么需要友元?
通常对于普通函数来说,要访问类的保护成员是不可能的,如果想这么做那么必须把类的成员都生命成为public,然而这做带来的问题是任何外部函数都可以毫无约束的访问、操作它,c++利用friend修饰符,可以让一些你设定的函数能够对这些保护数据进行操作,避免把类成员全部设置成public,最大限度的保护数据成员的安全。

友元的缺点:
友元能够使得普通函数直接访问类的保护数据,避免了类成员函数的频繁调用,可以节约处理器开销,提高程序的效率,但所矛盾的是,即使是最大限度大保护,同样也破坏了类的封装特性,这即是友元的缺点,在现在cpu速度越来越快的今天我们并不推荐使用它,但它作为c++一个必要的知识点,一个完整的组成部分,我们还是需要讨论一下的。

类成员函数的编译

对于类成员函数,不是一个对象对应一个单独的成员函数,而是同一类的所有对象共享这个成员函数体。
编译器在编译一个普通成员函数时,会隐式地增加一个形参 this,并把当前对象的地址赋值给 this,所以普通成员函数只能在创建对象后通过对象来调用,因为它需要当前对象的地址。而静态成员函数可以通过类来直接调用,编译器不会为它增加形参 this,它不需要当前对象的地址,所以不管有没有创建对象,都可以调用静态成员函数。

普通成员变量占用对象的内存,静态成员函数没有 this 指针,不知道指向哪个对象,无法访问对象的成员变量,也就是说静态成员函数不能访问普通成员变量,只能访问静态成员变量。

普通成员函数必须通过对象才能调用,而静态成员函数没有 this 指针,无法在函数体内部访问某个对象,所以不能调用普通成员函数,只能调用静态成员函数。
静态成员函数与普通成员函数的根本区别在于:普通成员函数有 this 指针,可以访问类中的任意成员;而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。

当程序编译后,成员函数的地址已经确定,当调用此成员函数时,会将当前对象的this指针传入成员函数,类的成员函数体只有一份,但成员函数之所以可以把各个对象的数据分开是因为,每次执行成员函数时,都会把当前对象的this指针(首地址)传入,对类内成员数据的访问,实际上是通过this指针访问数据;
this指针的由来
一个学生可以有多本书一样,而这些书都是属于这个同学的;同理,如果有很多个同学在一起,那么为了确定他们的书不要拿混淆了,最好的办法我想应该就是每个同学都在自己的书上写上名字,这样肯定就不会拿错了。
同理,一个对象的多个成员就可看作是这个对象所拥有的书;而在很多个对象中间,我们为了证明某个成员是自己的成员,而不是其他对象的成员,我们同样需要给这些成员取上名字。在C++中,我们利用this指针帮助对象做到这一点,this指针记录每个对象的内存地址,然后通过运算符->访问该对象的成员。this指针指向当前的对象。

this指针

this指针存在于类的成员函数中,指向被调用函数所在的类实例的地址。

class Point{   
int x, y;   
public:   
	Point(int a, int b) { x=a; y=b;}   
	void MovePoint( int a, int b){ x+=a; y+=b;}   
	void print(){ cout<<"x="<  
};   
void main( ){   
	Point point1( 10,10);   
	point1.MovePoint(2,2);   
	point1.print( );   
}   

当对象point1调用MovePoint(2, 2)函数时,即将point1对象的地址传递给了this指针。
MovePoint函数的原型应该是 void MovePoint( Point *this, int a, int b); 第一个参数是指向该类对象的一个指针,作为隐含参数。这样point1的地址传递给了this,所以在MovePoint函数中便显式的写成:void MovePoint(int a, int b) { this->x +=a; this-> y+= b;}  即可以知道,point1调用该函数后,也就是point1的数据成员被调用并更新了值。 即该函数过程可写成 point1.x+= a; point1. y + = b;

  • this指针是类的指针,指向对象的首地址。
  • this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。
  • this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置,可能是栈,也可能是寄存器,甚至全局变量。
  • 和静态函数的区别。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高,例如VC通常是通过ecx(计数寄存器)传递this参数的。

this指针用处

一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。this指针的类型取决于使用this指针的成员函数类型以及对象类型。

this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。

也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。

this指针创建时机

this在成员函数的开始执行前构造,在成员的执行结束后清除,这个生命周期同任何一个函数的参数是一样的,没有任何区别。每个成员函数开始前重新构造,结束后清除。因为在测试的时候this其实是一个右值(无法取地址)。

但是如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。

采用type xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。

采用new的方式创建对象的话,在堆里分配内存,new操作符通过eax(累加寄存器)返回分配的地址,然后设置给指针变量。之后去调用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx。

通过this访问成员变量

当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。

即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。

例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。

This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。

C++中this指针的用法详解

C++11 类的六个默认函数实现及使用

六个默认函数:

  • 构造函数A();
  • 析构函数~A();
  • 拷贝构造函数A(const A&);
  • 赋值运算符重载A& operator(const A& );
  • 移动构造函数A(const A&&);
  • 移动赋值运算符重载A& operator(const A&& );
struct A {
	//构造函数
	A(int x = 0) :val(x)
	{
		cout << "A()" << endl;
	}
	//析构函数
	~A() 
	{
		cout << "~A()" << endl;
	}
	//拷贝构造
	A(const A& _a) :val(_a.val)
	{
		cout << "A(const A&)" << endl;
	}
	//赋值构造
	A& operator=(const A& _a)
	{
		cout << "operator=(const A&)" << endl;
		if (this != &_a)
		{
			val = _a.val;
		}
		return *this;
	}
	//移动构造
	A(A&& _a)noexcept
	{
		val = _a.val;
		cout << "A(A&&)" << endl;
	}
	//移动赋值
	A& operator=(A&& _a)noexcept
	{
		if (this != &_a)
		{
			val = _a.val;
		}
		cout << "operator=(A&&)" << endl;
		return *this;
	}
private:
	int val;
};

int main() 
{
	A a1(10); //构造函数
	cout << endl;

	A a2(a1); //拷贝构造
	cout << endl;

	A a3;
	a3 = a1; //赋值构造
	cout << endl;

	A a4(move(a1)); //移动构造
	cout << endl;

	A a5;
	a5 = A();//移动赋值
	cout << endl;

	return 0;
}

【补充】
①在实现拷贝构造函数时,传参时一定要是引用类型,否则会导致无限拷贝构造导致堆栈溢出。

在函数调用的过程中,非引用类型的参数要进行拷贝初始化,即非引用类型的实参对象都会调用其拷贝构造函数,这样就形成了调用拷贝构造函数->复制实参->调用拷贝构造函数的无限循环,无法达成最终拷贝的目的,而如果是引用类型,就不会有这个问题了。

②同时在类属性中有指针成员时,要实现深拷贝,否则会出现非法访问或重复释放内存。
③在某些情况下(函数返回对象引用),对象拷贝后立即就被消耗了。拷贝构造就会造成性能上的浪费,而且深拷贝也会造成浪费。移动构造可以避免这种情况的发生。为了支持移动构造,C++11引入了右值引用。在类属性成员有指针类型存在时能提高执行效率和简化资源转移过程。赋值和移动赋值之间也类似。->【基础知识 - 引用】

构造函数

  1. 作用:构造函数(也叫构造器),在对象创建的时候自动调用,一般用于完成对象的初始化工作。
  2. 特点:
    (1) 函数名与类同名,无返回值(void都不能写),可以有参数,可以重载,可以有多个构造函数。
    (2) 一旦自定义了构造函数,必须用其中一个自定义的构造函数来初始化对象。

构造函数不能为虚函数:
虚函数指针vptr指针指向虚函数表,执行虚函数的时候,会调用vptr指针指向的虚函数的地址。
当定义一个对象的时候,首先会分配对象内存空间,然后调用构造函数来初始化对象。
vptr变量是在构造函数中进行初始化的。又因为执行虚函数需要通过vptr指针来调用。如果可以定义构造函数为虚函数,那么就会陷入先有鸡还是先有蛋的循环讨论中。

构造函数只能被重载,不能被重写
重写的定义是方法名称相同,方法参数列表,返回值也要相同,只有继承了父类,子类才可以重写,但是子类的名称和父类的名称是不能一样的,从而构造函数的名字也是不一样的,所以就谈不上重写了。

析构函数

  1. 作用:对象消亡时,自动被调用,用来释放对象占用的空间。是C++的类中定义的一个特殊的成员函数,用于清理对象。
  2. 特点:
    (1) 名字与类名相同,在前面需要加上"~"
    (2) 无参数,无返回值。
    (3) 一个类只能有一个析构函数,不能重载
    (4) 在对象销毁的时候 C++编译器自动调用
    (5) 不显式定义析构函数时会调用缺省析构函数

如果用户没有编写析构函数,编译系统会自动生成一个缺省的析构函数(即使自定义了析构函数,编译器也总是会为我们合成一个析构函数,并且如果自定义了析构函数,编译器在执行时会先调用自定义的析构函数再调用合成的析构函数),它也不进行任何操作。所以许多简单的类中没有用显式的析构函数。

【注意】
如果一个类中有指针,且在使用的过程中动态的申请了内存,那么最好显示构造析构函数在销毁类之前,释放掉申请的内存空间,避免内存泄漏。

当基类的析构函数为非虚函数时,删除一个基类指针指向的派生类实例时,只会清理派生类从基类继承过来的资源,而派生类自己独有的资源却没有被清理,因此这才需要将析构函数设置为虚函数。(如果用基类的指针指向一个派生类对象。再删除这个对象的时候,只会执行基类的析构函数而不会执行派生类的析构。只有把基类的析构设为虚函数的时候才会执行派生类的析构。)

析构函数可以是虚函数:
当使用父类指针或者引用调用子类,最好将父类的析构函数声明为虚函数,避免内存泄漏。
例:子类B继承自父类A:A *p = new B; delete p;
①如果A的析构函数不是虚函数:delete p; 仅调用A的析构函数,只释放了B对象中的A部分,派生出的新的部分未释放掉。
②如果A的析构函数是虚函数:delete p; 将会先调用B的析构函数,再调用A的析构函数,释放B对象的所有空间。B *p = new B; delete p; 也是先调用B的析构函数,再调用A的析构函数。

默认的析构函数不是虚函数

C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。
因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数

基类的析构函数定义为虚函数

将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏

不论基类的析构函数是否为virtual的,派生类的对象在过期时都是 先调用自己析构函数,然后再调用基类的析构函数。

构造、析构顺序

1、构造函数的调用顺序
基类构造函数、对象成员构造函数、派生类本身的构造函数

2、析构函数的调用顺序
派生类本身的析构函数、对象成员析构函数、基类析构函数(与构造顺序正好相反)

3、特例
局部对象,在退出程序块时析构
静态对象,在定义所在文件结束时析构
全局对象,在程序结束时析构
继承对象,先析构派生类,再析构父类
对象成员,先析构类对象,再析构对象成员

析构构造与初始化列表没有关系 ,只跟定义的顺序有关。
且构造是从小到大构造,析构时反过来从外面的开始析构。析构就是构造的逆过程
(1)派生类本身的析构函数
(2)对象成员的析构函数
(3)基类析构函数

class Node1{
public:
    Node1() {cout << "create 1" << endl;}
    ~Node1() {cout << "destroy 1" << endl;}
};
class Node2{
public:
    Node2() {cout << "create 2" << endl;}
    ~Node2() {cout << "destroy 2" << endl;}
};
class Node3{
public:
    Node1 a1;
    Node2 a2;
    Node3() : a2(), a1() {cout << "create 3" << endl;}
    ~Node3() {cout << "destroy 3" << endl;}
};
Node3 a;
/*
create 1
create 2
create 3
destroy 3
destroy 2
destroy 1
*/

构造、拷贝构造与赋值操作符

构造函数
对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数。

赋值运算符
对象存在,用别的对象给它赋值,这属于重载“ = ”号运算符的范畴,“ = ”号两侧的对象都是已存在的

拷贝构造函数
对象不存在,但是使用别的已经存在的对象来进行初始化。
A a; A b(a); A b = a; //都是拷贝构造函数来创建对象b

强调:这里b对象是不存在的,是用a 对象来构造和初始化b的。

  • 在C++中,3种对象需要复制,此时拷贝构造函数会被调用:
    1)一个对象以值传递的方式传入函数体
    2)一个对象以值传递的方式从函数返回
    3)一个对象需要通过另一个对象进行初始化

  • 因为系统提供的默认拷贝构造函数工作方式是内存拷贝,在拷贝过程中是按字节复制的,对于指针型成员变量只复制指针本身,而不复制指针所指向的目标,也就是浅拷贝。如果对象中用到了需要手动释放的对象(析构函数释放内存),则会引起错误出现问题,这时就要手动重载拷贝构造函数,实现深拷贝。

深拷贝与浅拷贝

1、定义
浅拷贝:如果复制的对象中引用了一个外部内容(例如分配在堆上的数据),那么在复制这个对象的时候,让新旧两个对象指向同一个外部内容,就是浅拷贝。(指针虽然复制了,但所指向的空间内容并没有复制,而是由两个对象共用,两个对象不独立)

深拷贝:如果在复制这个对象的时候为新对象制作了外部对象的独立复制,就是深拷贝。

2、区别
举例:我们首先创建了对象p1,并用括号法调用有参构造函数为其赋值,随后用拷贝构造函数创建了p2,其信息与p1完全相同。代码出问题的关键如图所示,假设p1的height中存放的内存地址为0x0011(假设),那么根据拷贝构造函数的实现代码来看,p2的height中存放的地址也相同,即两者指向同一块堆内存空间。
我们都知道,p1和p2都属于局部对象,存放在栈区,当主函数执行完二者都要被释放,这时候就要调用析构函数了。那么问题来了,两个对象是同时被释放的吗?很显然不是,栈的特点就是后进先出,根据析构函数的实现,p2中height指向的内存会先被释放。
在这里插入图片描述
下图,图中的叉号表示内存编号为0x0011(也就是上面说的height指针指向的那块堆内存)在p2中的height指针被释放时已经被释放,那么此时p1调用析构函数,就会重新释放这块内存,这就是我们所说的堆内存重复释放问题。按拷贝构造函数实现的就叫做浅拷贝,也就是最简单的赋值拷贝操作,浅拷贝所带来的一大问题就是这里的堆内存重复释放
在这里插入图片描述
问题的出现在于堆内存的重复释放,那么我们自然会想到,让p1和p2的height指针分别指向不同的堆内存,这样就不会出现重复释放的问题了,于是我们可以修改一下拷贝构造函数的实现,如下代码段所示,这便是深拷贝,其特点是在堆区重新申请空间,进行拷贝操作。

// 拷贝构造函数
Person::Person(const Person& p) {
	cout << "拷贝构造函数调用" << endl;
	name = p.name;
	gender = p.gender;
	age = p.age;
	height = new int(*p.height);
}

在这里插入图片描述

类内数据成员

引用数据成员

const数据成员不能在构造函数里初始化,必须通过构造函数初始化列表初始化。(原因:const变量不能在成员变量那里声明,然后再在构造函数里初始化,那样会报错,只能在构造函数体运行前完成初始化赋值工作,就是在构造函数初始化列表里初始化。否则会造成引用未初始化错误。)
但若定义了类却没有在程序中使用到,则不会报出编译错误。

  • 构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面跟一个放在括号中的初始化式。
class A{
public:
	A(int _x) : x(_x){};
	const int x;
};

int main()
{
//	A a(1);//没有用到就不报错
	return 0;
}

用初始化成员列表

类成员初始化的顺序和其在类中声明时的顺序是一致的,与在初始化列表的先后顺序无关

需要使用初始化成员列表的几种情况:

  1. 需要初始化的数据成员是对象的情况(这里包含了继承情况下,通过显式调用父类的构造函数对父类数据成员进行初始化)
    数据成员是对象,并且这个对象只有含参数的构造函数,没有无参数的构造函数;

如果我们有一个类成员,它本身是一个类或者是一个结构,而且这个成员它只有一个带参数的构造函数,而没有默认构造函数,这时要对这个类成员进行初始化,就必须调用这个类成员的带参数的构造函数,如果没有初始化列表,那么他将无法完成第一步,就会报错。

class Test
{
public:
    Test(int, int, int){
   		cout <<"Test" << endl;
	};
private:
    int x;
    int y;
    int z;
};

class Mytest 
{
public:
    Mytest() : test(1,2,3){       //初始化
    	cout << "Mytest" << endl;
    };
private:
    Test test; //声明 
};

int main()
{
	Mytest test;
	
	return 0;
}
  1. 需要初始化const修饰的类成员或初始化引用成员数据
    const对象或引用只能初始化不能赋值,构造函数的函数体内只能赋值而不是初始化,因此初始化列表是初始化const和引用的唯一机会。

  2. 子类初始化父类的私有成员,需要在(并且也只能在)参数初始化列表中显式调用父类的构造函数。

class Test{
public:
   Test(){};
   Test(int x){ int_x = x;};
   void show(){cout << int_x << endl;}
   
private:
   int int_x;
};

class Mytest : public Test{
public:
	Mytest() : Test(110){
	// Test(110);  
	// 构造函数只能在初始化列表中被显示调用,
	// 不能在构造函数内部被显示调用
	};
};

int main()
{
   Test *p = new Mytest();
   p->show();
   return 0;
}

如果在构造函数内部被显示调用输出结果是:-842150451(原因是这里调用了无参构造函数);

如果在初始化列表中被显示调用输出结果是:110

具体原因见C++必须使用【初始化列表】初始化数据成员的三种情况

赋值和初始化是不同的,构造函数需要初始化的数据成员,不论是否显式的出现在构造函数的成员初始化列表中,都会在该处完成初始化

C++新关键字default与delete

default和delete是C++11新添加的关键字,依靠这两个关键字C++编译器可以控制函数的默认生成和删除,这是对C++98标准的很大升级。这儿需要说明的是default仅仅可以控制类的特殊成员函数的默认生成(6个),而delete可应用于任何函数。

default

明确默认的函数声明是一种新的函数声明方式,在C++11发布时做出了更新。C++11允许添加= default说明符到函数声明的末尾,以将该函数声明为显式默认构造函数,这就使得编译器为显式默认函数生成了默认实现,它比手动编程函数更加有效(性能上一般会比用户自己定义的更好,而且也更有标志性,便于代码的阅读)。

在早期的C++中,如果需要一些接受一些参数的构造函数,同时需要一个不接收任何参数的默认构造函数,就必须显式地编写空的默认构造函数。为了避免手动编写空默认构造函数,C++11引入了显式默认构造函数的概念,从而只需在类的定义中编写空默认构造函数而不需要在实现文件中提供其实现。

注意:即使用 = default 告诉编译器生成默认构造函数,编译器也只有在检测到确实是需要默认构造函数的时候才会生成。

default关键字可以显式要求编译器生成合成构造函数(默认构造函数,拷贝构造函数和拷贝赋值运算符),防止在调用相关构造函数类型时没有定义而报错。

delete

1、delete性能更好
对标delete:将函数声明为private / protected并且故意不实现以禁止它的使用,如果有函数访问这些函数(通过成员函数或者友好类)在链接的时候会导致没有定义而触发错误。C++有一个更好的方法可以基本上实现同样的功能:新增的delete关键字。①删除的函数和声明为私有函数的区别看上去只有一些不一样,但是区别比想象的要多。通过delete删除的函数不能通过任何方式被使用,即便是其他成员函数或者友好函数试图复制的时候也会导致编译失败。这是对C++98中的行为的升级,因为在C++98中直到链接的时候才会诊断出这个错误。②删除函数一个重要的优势是任何函数都可以是删除的,然而仅有成员函数才可以是私有的。③可以阻止那些应该被禁用的模板实现,而设为私有成员函数无法完成。(拒绝特定类型的函数的使用)

bool isLucky(int number);           // 原本的函数
bool isLucky(char) = delete;        // 拒绝char类型
bool isLucky(bool) = delete;        // 拒绝bool类型
bool isLucky(double) = delete;      // 拒绝double和float类型

2、将= delete写在public里
删除函数被声明为公有的,而不是私有的。这样设计的原因是,当客户端程序尝试使用一个成员函数的时候,C++会在检查删除状态之前检查可访问权限。当客户端代码尝试访问一个删除的私有函数时,一些编译器仅仅会警报该函数为私有,尽管这里函数的可访问性并不本质上影响它是否可以被使用。当把私有未定义的函数改为对应的删除函数时,牢记这一点是很有意义的,因为使这个函数为公有的可以产生更易读的错误信息

条款11:优先使用delete关键字删除函数而不是private却又不实现的函数

继承与组合

类的组合和继承一样,是实现代码复用、功能重用的重要方式。

定义

组合:在新类里面创建原有类的对象,重复利用已有类的功能。(has-a关系,用已有的对象拼成一个对象)
继承:可以使用现有类的功能,并且在无需重复编写原有类的情况下对原有类进行功能上的扩展。(is-a关系,用类做出一个新的类)

组合、继承的优缺点:

组合
①优点:不破坏封装,整体类与局部类之间松耦合,彼此相对独立;具有较好的可扩展性;支持动态组合,在运行时整体对象可以选择不同类型的局部对象;整体类可以对局部类进行包装,封装局部类的接口,提供新的接口。
②缺点:整体类不能自动获得和局部类同样的接口;创建整体类的对象时,需要创建所有局部类的对象。

继承:
①优点:子类能自动继承父类的接口;创建子类的对象时,无须创建父类的对象;
②缺点:破坏封装,子类与父类之间紧密耦合,子类依赖于父类的实现,子类缺乏独立性;支持扩展,但是往往以增加系统结构的复杂度为代价;不支持动态继承,在运行时,子类无法选择不同的父类;子类不能改变父类的接口。

组合、继承的选择:

优先选择组合
①除非两个类之间是“is-a”的关系,否则不要轻易的使用继承,不要单纯的为了实现代码的重用而使用继承,因为过多的使用继承会破坏代码的可维护性,当父类被修改时,会影响到所有继承自它的子类,从而增加程序的维护难度和成本。
②不要仅仅为了实现多态而使用继承,如果类之间没有“is-a”的关系,可以通过实现接口与组合的方式来达到相同的目的。设计模式中的策略模式可以很好的说明这一点,采用接口与组合的方式比采用继承的方式具有更好的可扩展性。

要优先使用组合而不是继承,原因:
1、组合是黑箱复用,对局部类的内部细节不可见;继承是白箱复用,对类的内部细节可见,破坏封装性。
2、继承在编译时刻就已经定义了,在运行时不能改变从父类继承的实现;而组合可以在运行时期通过对象的替换实现。

同名成员函数关系

在这里插入图片描述

重载、重写与重定义

重载(overload):是函数名相同,参数列表不同,override只是在类的内部存在。

class Box{
public:
	Box();
    Box(int h, int w, int len): height(h), width(w), length(len){}
    Box(int l, int y): width(l), length(y){width = 3;length = 4;} 
    int volume();
private:
    int height;
    int width;
    int length;
};

Box::Box() {
    height=3;
    width=4;
    length=5;
}

int Box::volume()
{
	printf("hello\n");
}

int main()
{
	Box b(1,2,3);
	b.volume() ;
	return 0;
}

重写(override),也叫覆盖。子类重新定义父类中有相同名称和参数的虚函数(virtual)。在继承关系之间。C++利用虚函数实现多态。

重写的特点:
①被重写的函数不能是static的,必须是virtual。
②重写函数必须有相同的类型,名称和参数列表。
③重写函数的访问修饰符可以不同,尽管父类的virtual方法是private的,派生类中重写改写为public、protected也是可以的。

这是因为被virtual修饰的成员函数,无论他们是private/protect/public的,都会被统一放置到虚函数表中。对父类进行派生时,子类会继承到拥有相同偏移地址的虚函数表(相同偏移地址指的是各虚函数先固定与VPTR指针的偏移),因此就允许子类对这些虚函数进行重写。

class A{
public:
	virtual int say_no();
};

int A::say_no() {
	printf("nonono\n"); 
}

class B : public A{
public:
	int say_no();
};

int B::say_no() {
	printf("yesyesyes\n");
}

int main()
{
	B b;
	b.say_no() ;
	return 0;
} 

重定义(redefining),也叫隐藏。子类重新定义父类有相同名称的非虚函数(参数列表可以不同)。可以理解成发生在继承中的重载。
子类若有和父类相同的函数,那么,这个类将会隐藏其父类的方法。除非你在调用的时候,强制转换成父类类型,例如B b; b.A::say_no(1) ;。否则在子类和父类之间尝试做类似重载的调用是不能成功的。

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

int A::say_no(int a) {
	printf("nonono\n"); 
}

class B : public A{
public:
	int say_no();
};

int B::say_no() {
	printf("yesyesyes\n");
}

int main()
{
	B b;
	b.say_no() ;
	return 0;
} 

总结

1、成员函数重载特征:
①相同的范围(在同一个类中)
②函数名字相同
③参数不同
④virtual关键字可有可无

2、重写(覆盖)是指派生类函数覆盖基类函数,特征是:
①不同的范围,分别位于基类和派生类中
②函数的名字相同
③参数相同
④基类函数必须有virtual关键字

3、重定义(隐藏)是指派生类的函数屏蔽了与其同名的基类函数,规则如下:
①如果派生类的函数和基类的函数同名,但是参数不同,此时,不管有无virtual,基类的函数被隐藏。
②如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字,此时,基类的函数被隐藏。否则就是重写了。

虚函数

前言
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。

为了满足多态与泛型 编程这一性质,C++允许用户使用虚函数(virtual function)来完成 运行时决议 这一操作,这与一般的 编译时决定 有着本质的区别。

虚函数的作用在于通过父类的 指针或引用 来调用子类的成员函数

C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

虚函数定义

class Test{
public:
    virtual int getA(){} // 虚函数
    virtual int getB() = 0; // 纯虚函数
};
// 可以在类外实现纯虚函数,但不能在定义的时候实现
int Test::getB() {
    return 0;
}

纯虚函数

虚函数存放在虚函数表中,但是纯虚函数在虚函数表中对应的位置上是一个 pure_virtual_called()函数实例,他扮演该位置的空间保卫者的角色,如果被调用就会触发执行期异常处理,除非该纯虚函数被后代实现(重写),该位置才会被覆盖为普通的虚函数。

纯虚函数只能被静态地调用,不能经由虚拟机制调用

  • 即使在接口类的纯虚函数声明了,但在子类中,也需要再次实现,否则会报错,即使没有声明对象。
  • 特例是纯虚析构函数,不用在派生类中实现也可以,但需要在基类中实现。否则在声明对象的时候会编译错误,但是没有声明对象就不会报错。

在父类中纯虚函数是否实现取决于设计者,但纯虚析构函数一定要定义实现!

因为每一个继承的子类的析构函数会被扩张,将以静态调用的方式调用每一个上层的基类的析构函数。因此只要缺乏任何一个基类的析构函数,都会导致链接失败

即就比如,如果要将一个基类定义为抽象类,但是没有合适的纯虚函数时,就可以将析构函数定义为纯虚函数。但是一定要有实现,因为当基类指针指向派生类的对象时,如果对象释放掉,依次调用派生类的析构函数,基类的析构函数。如果基类没有析构函数,那编译器应该会出问题。

  • 纯虚函数

    纯虚函数没有函数体,同时在定义的时候,其函数名后面要加上= 0

    对于一个含有纯虚函数的类(抽象类)来说,其无法进行实例化
    【[Note] because the following virtual functions are pure within ‘Class_Name’:】
    含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的类可以覆盖接口。我们不能(直接)创建一个抽象基类的对象。

无法实例化原因:
类中出现纯虚函数,等于告诉编译器在VTABLE中为函数保留一个间隔,但在这个特定间隔中不放地址。即纯虚函数在类的vtable表中对应的表项被赋值为0,也就是指向一个不存在的函数。只要有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。由于编译器绝对不允许有调用一个不存在的函数的可能,所以该类不能生成对象。在它的派生类中,除非重写此函数,否则也不能生成对象。这样当某人试图创建这个类时,编译器会由于不能安全的创建一个纯抽象类的对象而发出一个出错信息,这样编译器就可以保证抽象类的纯洁性,不会被误用。

class Test{
public:
    virtual int getA(){}
    virtual int getB() = 0; // 纯虚函数
};

class Mytest : public Test{
public:
	int getB(){}  //重写,使派生类能生成对象 
};
Mytest p;
  • 虚表存储在哪里?

    在gcc编译器的实现中虚函数表vtable存放在可执行文件的只读数据段 .rodata 中。

  • C++类虚函数的内存分布

    C++ 虚函数 虚内存的内存模型

  • 在某些情况下,我们希望对虚函数的调用不进行动态绑定,而是强迫其执行虚函数的某个特定版本

 //强行调用基类中定义的函数版本而不管base P的动态类型到底是什么
  double price = basePtr->Base::net_price()
  • 使用方法

    • 在派生类中重新定义此函数,要求函数名,函数类型,函数参数个数和类型全部与基类的虚函数相同,并根据派生类的需要重新定义函数体。

      C++规定,当一个成员函数被声明为虚函数后,其中派生类的具有相同参数类型和相同参数个数类型的相同类型的同名函数自动称为虚函数,因此在派生类重新声明该虚函数时,可以加 virtual,也可以不加,但习惯上一般在每层声明该函数时都加上,使程序更加清晰。

      虚函数的声明与定义要求非常严格,只有在子函数中的虚函数与父函数一模一样的时候(包括限定符)才会被认为是真正的虚函数,不然的话就只能是重载(?此处应该是指重定义)。这被称为虚函数定义的同名覆盖原则,意思是只有名称完全一样时才能完成虚函数的定义。

      如果在派生类中没有对基类的虚函数重新定义,则派生类简单的继承其基类的虚函数。(此时派生类的虚指针指向的是基类中的虚表)

    • 定义一个指向基类对象的指针变量,并使它指向同一类族中需要调用该函数的对象。

    • 通过该指针变量调用此虚函数,此时调用的就是指针变量指向的对象的同名函数。

虚函数实现

虚函数的实现是由两个部分组成的,虚指针虚函数表

  • 虚函数表是针对类的,一个类的所有对象的虚函数表都一样,类的所有对象共享这个类的虚函数表

  • 每个对象内部都保存一个指向该类虚函数表的指针vptr,每个对象的vptr的存放地址都不一样,但是都指向同一虚函数表。

虚指针 (virtual function pointer)

从本质上来说就只是一个指向函数的指针,与普通指针并无区别。

它指向用户所定义的虚函数,具体是在子类里的实现。当子类调用虚函数的时候,实际上是通过调用该虚函数指针从而找到接口。

虚函数指针是确实存在的数据类型,在一个被实例化的对象中,它总是被存放在该对象的地址首位, 这种做的目的是为了保证运行的快速性。

与对象的成员不同,虚函数指针对外部是完全不可见的,除非通过直接访问地址的做法或者 debug 模式中,否则它不可见的也不能被外界调用。

  • 直接访问:
	Test p;
	cout<<(int*)(&p)<<endl;//虚函数表地址 
	cout<<(int*)*(int*)(&p)<<endl;//虚函数表里第一个函数的地址

只有拥有虚函数的类才会拥有虚指针,每一个虚函数也都会对应一个虚函数指针。(后半句含义为:含有虚函数的类实例化的 对象 中都有一个虚函数指针,且同一个虚函数类实例化的对象的虚函数指针都指向同一个地址,即一个类只有一个虚函数表,实例化的对象共用一个虚函数表。)
所以拥有虚函数的类的所有对象都会因为虚函数产生额外的开销,并且也会在一定程度上降低程序速度。

虚函数表

每个类的实例化对象都会拥有虚函数指针并且都排列在对象的地址首部。而它们也都是按照一定的顺序组织起来的,从而构成了一种表状结构,称为虚函数表(virtual table)

虚函数的实现

在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在 代码段(.text) 中。

当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。

使用了虚函数,会增加访问内存开销,降低效率。

原因:
虚函数其实最主要的性能开销在于它阻碍了编译器内联函数和各种函数级别的优化,导致性能开销较大,在普通函数中log(10)会被优化掉,它就只会被计算一次,而如果使用虚函数,log(10)不会被编译器优化,它就会被计算多次。如果代码中使用了更多的虚函数,编译器能优化的代码就越少,性能就越低。
虚函数通常通过虚函数表来实现,在虚表中存储函数指针,实际调用时需要间接访问,这需要多一点时间。
然而这并不是虚函数速度慢的主要原因,真正原因是编译器在编译时通常并不知道它将要调用哪个函数,所以它不能被内联优化和其它很多优化,因此就会增加很多无意义的指令(准备寄存器、调用函数、保存状态等),而且如果虚函数有很多实现方法,那分支预测的成功率也会降低很多,分支预测错误也会导致程序性能下降。
如果你想要写出高性能代码并频繁的调用虚函数,注意如果用其它的方式(例如if-else、switch、函数指针等)来替换虚函数调用并不能根本解决问题,它还有可能会更慢,真正的问题不是虚函数,而是那些不必要的间接调用。

正常的函数调用:
①复制栈上的一些寄存器,以允许被调用的函数使用这些寄存器;
②将参数复制到预定义的位置,这样被调用的函数可以找到对应参数;
③入栈返回地址;
④跳转到函数的代码,这是一个编译时地址,因为编译器/链接器硬编码为二进制;
⑤从预定义的位置获取返回值,并恢复想要使用的寄存器。

而虚函数调用与此完全相同,唯一的区别就是编译时不知道函数的地址,而是:
①从对象中获取虚表指针,该指针指向一个函数指针数组,每个指针对应一个虚函数;
②从虚表中获取正确的函数地址,放到寄存器中;
③跳转到该寄存器中的地址,而不是跳转到一个硬编码的地址。

通常使用虚函数没问题,它的性能开销也不大,而且虚函数在面向对象代码中有强大的作用。但是也不能无脑使用虚函数,特别是在性能至关重要的或者底层代码中,而且大项目中使用多态也会导致继承层次很混乱。
【拓展】
替代虚函数的几个思路:

  • 使用访问者模式来使类层次结构可扩展;
  • 使用普通模板替代继承和虚函数;
  • C++20中的concepts用来替代面向对象代码;
  • 使用variants替代虚函数或模板方法。

回避虚函数的机制

在某些情况下,我们希望对虚函数不要进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符::可以实现这一目的。
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。

  //强行调用基类中定义的函数版本而不管base P的动态类型到底是什么
  double price = basePtr->Base::net_price()

如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

友元关系不能传递,也不能继承。每个类负责控制各自成员的访问权限。

或者使用using

using 改变个别成员的可访问性

  • using访问范围不可变大(父类私有还是不可访问)
  • 如果using声明语句位于public部分,则类的所有用户都能访问它。
  • 如果using声明语句位于protected部分,则该名字对于成员、友元和派生类是可访问的。
  • 如果using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问。
  • 派生类只能为那些它可以访问的名字提供using声明。
//.h
class Base {
public:
	int base_public = 1;
	void func1();
protected:
	int base_protect = 2;
	void func2();
private:
	int base_private = 3;
	void func3();
};

class Derive1 : private Base { //私有继承
public:
	//在public作用域声明基类中的成员
    //成功,基类公有->派生类公有
	using Base::base_public; 
    //成功,基类公有->派生类公有
	using Base::func1; 
    //成功,基类保护->派生类公有
	using Base::func2; 
protected:
    //成功,基类保护->派生类保护
	using Base::base_protect; 
    //错误,基类私有->派生类保护;编译器报错 , 不可访问
	using Base::func3; 
private:
    // 错误, 基类私有->派生类私有;编译器报错 , 不可访问
	using Base::base_private; 
};

在派生类中使用using声明改变基类成员的可访问性

防止继承的发生

有时我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否适合作为一个基类,为了实现这以目的,在类名后跟一个关键字final。

class A final{
};

class B : public A{

};

【[Error] cannot derive from ‘final’ base ‘A’ in derived type ‘B’】

静态函数、常函数

静态函数、常函数能否定义为虚函数,不能:

  1. static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。
  2. 静态与非静态成员函数之间有一个主要的区别,那就是静态成员函数没有this指针。

虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable。对于静态成员函数,它没有this指针,所以无法访问vptr。

这就是为何static函数不能为virtual,虚函数的调用关系:this -> vptr -> vtable ->virtual function。

哪些函数不能为虚函数

成为虚函数的条件:
1.要能取地址
2.依赖对象调用

简单总结就是,不属于类的函数,以及构造函数。

  1. 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;(系统调用,不依赖对象调用)
  2. 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。(不依赖对象调用)
  3. 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。(不依赖对象调用)
  4. 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。(不依赖对象调用)
  5. 内联函数,不能取地址,函数在调用点直接展开。

虚继承

C++继承详解之三——菱形继承+虚继承内存对象模型详解vbptr

  • 概念:
    虚继承是面向对象编程中的一种技术,是指 一个指定的基类在继承体系结构中,将其成员数据实例共享给也从这个基类型直接或间接派生的其它类。虚拟继承是多重继承中特有的概念,虚拟基类是为解决多重继承而出现的。

在多继承情况下,不同途径继承来的同一基类,会在子类存在多个拷贝(比如菱形继承问题)。这样会产生两个问题:浪费空间,存在二义性。【[Error] request for member ‘xxx’ is ambiguous】
解决:对出现多份的数据在最近的继承前加virtual

C++提供虚基类,使得继承间接共同使用同一基类时,只保留一份成员。子类继承可以继承多个虚基类表指针。

  • 原理:
    每个虚继承的子类都有一个虚基类表指针vbptr,它指向一个虚基类表vbtable。虚基类表中记录了数据相对于虚基类指针的偏移地址,通过偏移地址就可以找到虚基类的成员(数据)。
    vbptr ==> vbtable

虚基类依旧占据继承类的存储空间。

class A {
public:
    A() {cout << "constructor A" << endl;}
    ~A() {cout << "destructor A" << endl;}
};
class B : virtual public A {
public:
    B() {cout << "constructor B" << endl;}
    ~B() {cout << "destructor B" << endl;}
};
class D : virtual public A {
public:
    D() {cout << "constructor D" << endl;}
    ~D() {cout << "destructor D" << endl;}
};

class C : virtual public D, virtual public B {
public:
    C() {cout << "constructor C" << endl;}
    ~C() {cout << "destructor C" << endl;}
};
// 这样 A 只会构造一次
C c;

如图:C1与C2都是虚拟继承,故会在C1,C2内存起始处存放一个vbptr,为指向虚基类表的指针。
在这里插入图片描述
c1(无任何成员,只有一个虚基类指针)占了四个字节,存了一个指针变量,指针变量的内容就是c1的vbptr指向的虚基类表的地址。指向c1.vbptr指向的虚基类表中存放的内容:这个虚基类表有八个字节,分别存的为0和4。虚基类表存放的为两个偏移地址,分别为0和4。
 其中0表示c1对象地址相对于存放vptr指针的地址的偏移量,可以用&c1->vbptr_c1表示。其中vptr指的是 指向虚表的指针,而虚表是定义了虚函数后才有的,由于我们这里没有定义虚函数,当然也就没有vptr指针,所以偏移量为0。
 4表示c1对象中基类对象部分相对于存放vbptr指针的地址的偏移量,可以用&c1(B)->&vbptr表示,其中&c1(B)表示对象c1中基类B部分的地址。
  c2的内存布局与c1一样,因为C1,C2都是虚继承自B基类,(C1,C2都没有加数据成员)。

总结一下,因为C1,C2是虚继承自基类B,所以编译器会给C1,C2中生成一个指针vbptr指向一个虚基类表,即指针vbptr的值是虚基类表的地址。而这个虚基类表中存储的是被继承的虚基类子对象相对于虚基类表指针的偏移量。

虚基类表中分两部分
①第一部分存储的是对象相对于存放vbptr指针的偏移量,可以用&(对象名)->vbptr_(对象名)来表示。对c1对象来说,可以用&c1->vbprt_c1来表示。vptr指针是指向虚表的指针,而只有在类中定义了虚函数才会有虚表,因为我们这个例子中没有定义虚函数,所以没有vptr指针,所以第一部分偏移量均为0。

②表的第二部分存储的是对象中基类对象部分相对于存放vbptr指针的地址的偏移量,我们知道在本例中 基类对象与指针偏移量 就是指针的大小。

D的内存布局:存放了两个虚基类指针,指向的虚基类表中存储了各类(本类与基类)对象与vbptr的偏移量。
在这里插入图片描述
添加了数据成员的D类内存布局示意图:
在这里插入图片描述

C++中struct与class的区别

相同点

  • 两者都拥有成员函数、公有和私有部分
  • 任何可以使用class完成的工作,同样可以使用struct完成

不同点

  • C++中把struct当成类处理,两者中如果不对成员不指定访问权限,struct默认是公有的,class则默认是私有的
  • class默认是private继承,而struct模式是public继承

引申:C++和C的struct区别

  • C语言中:struct是用户自定义数据类型(UDT);C++中struct是抽象数据类型(ADT),支持成员函数的定义,(C++中的struct能继承,能实现多态)
  • C中struct是没有权限的设置的,且struct中只能是一些变量的集合体,可以封装数据却不可以隐藏数据,而且成员不可以是函数
    C++中,struct增加了访问权限,且可以和类一样有成员函数,成员默认访问说明符为public(为了与C兼容)。
  • struct作为类的一种特例是用来自定义数据结构的。一个结构标记声明后,在C中必须在结构标记前加上typedef,才能做结构类型名(C++中结构体标记(结构体名)可以直接作为结构体类型名使用,此外结构体struct在C++中被当作类的一种特例。
typedef struct Student{
	int a;
}Stu;
//这个Stu叫做Student结构体的别名,而结构名Student可以省略。

使用了typedef之后,声明变量时可以用别名声明,如:
Stu student1; 否则,必须用 struct Student student1来声明;(即Stu==struct Student)

总结如下:
1、C中定义的时候需要在前面加上typedef,而C++中不用
2、C++把struct当成类处理,所以C++的struct中可以自己设定访问权限,而C中没有访问权限。
3、C++把struct当成类处理,所以C++中struct可以有成员函数,而C中不能有成员函数。
3、C++把struct当成类处理,所以C++中struct可以继承,而C中不能继承。

【注意】
第一点里引申,在C++中使用typedef会造成区别。

//在C++中要特别注意,使不使用typedef关键字都可以在定义后面加上一串或多串字符,用逗号隔开。
//但是意义不同,使用了typedef关键字后面的字符相当于别名,访问内部数据时需要先定义一个变量再访问:
Stu1 student;
student.a=1;
//而未使用typedef就是一个变量。访问内部数据时可以直接访问,Stu2.a=1; 

模块中存在重复定义问题

链接器对多个目标文件进行链接,如果发现有相同的重复定义就会报错。把生成的目标文件打包成静态库,并再次尝试链接,链接器就会根据人为指定的链接顺序进行链接,如果lib1.a与lib2.a中有重复的定义,那么将优先选择使用lib1.a中的定义,而lib2.a中同样的定义(通常是不同的实现)将被链接器忽略。

避免重复定义产生bug及正确处理多重定义:
在开发中建议使用命名空间来显示指明调用的对象,以防止上述这种不明显的陷阱错误。通过命名空间的方式,可以在语义上明确的指明我们要使用的对象,避免错误。

// 1.h
#ifndef __C1__
#define __C1__
#include<stdio.h>
namespace C1
{
    void func();   
}
#endif
// 1.c
#include "1.h"
namespace C1
{
    void func()           
    {
        printf("1.c\n");
    }
}
// 2.h
#ifndef __C2__
#define __C2__
#include<stdio.h>
namespace C2
{
    void func();    
}
#endif
// 2.c
#include "2.h"
namespace C2
{
    void func()         
    {
        printf("2.c\n");
    }
}
// main.c
#include<stdio.h>
#include "1.h"
#include "2.h"
using namespace C1;
using namespace C2;
int main()
{
    printf("main\n");
    //func();// which to call?
    C1::func();
    C2::func();
    return 0;
}   
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值