【C++】第四章 类

该文章内容整理自《C++ Primer Plus(第6版)》、《Effective C++(第三版)》、以及网上各大博客

文章目录

  • 类的设计
  • 特殊成员函数
    • 构造函数
    • 析构函数
    • 复制构造函数
    • 赋值运算符
  • 类的常量
    • this指针
    • 类中常量的定义
    • const常量的初始化
    • const成员函数
    • static成员函数
    • 返回const对象
  • 运算符重载
    • 强制转换类型运算符重载
    • new和delete运算符重载
  • 友元
    • 友元函数
    • 友元类
    • 友元成员函数
    • 双向友元关系
  • 继承
    • 继承的定义
    • 构造函数
    • 派生类和基类
    • 多态
    • 多继承
    • 多重继承
  • 类的嵌套定义
    • 嵌套类
    • 局部类

类的设计

C++对结构进行了扩展,结构和类唯一的区别是,结构默认访问类型是public,而类为private

成员函数定义时,有两种定义方式,一种是直接在类声明内部定义函数,此时函数都将会自动成为内联函数;第二种是在类声明外定义,此时需要在函数名前加上所属类以及作用域运算符,如void Stock::update(double price) {}。要想在类声明外定义的函数为内联函数只需要在定义前加上inline限定符就可以了(另外,由于内联函数的特殊规则要求每个使用他们的文件中都对其进行定义,因而要确保内敛定义对多个文件都可用,最简单的方法是将内敛定义放到定义类的头文件中)

每个类对象都有自己的存储空间用于存储其内部变量和类成员,但对于用一个类的所有对象共享同一组类方法

特殊成员函数

C++会自动提供一些特殊的成员函数:

  • 默认构造函数,如果没有定义构造函数
  • 默认析构函数,如果没有定义
  • 复制构造函数,如果没有定义
  • 赋值运算符,如果没有定义
  • 地址运算符,如果没有定义
  • 移动构造函数,C++11提供
  • 移动赋值运算符,C++11提供

在C++11中提供了default关键字来控制默认函数的使用。假如要使用某个默认函数,但是这个函数由于某种原因不会自动创建,如编写了移动构造函数,因而编译器不会自动创建默认构造函数、复制构造函数、和复制赋值构造函数,此时可使用default显式声明这些方法的默认版本,而编译器将为这些函数自动生成函数体。这种特性只适用于类的特殊成员函数。如

class Test {
public:
	Test(Test &&);
	Test() = default;
	Test(const Test &) = default;
	Test & operator=(const Test &) = default;
}

另一方面,C++11还提供了delete来禁止编译器使用特定方法。如要禁止复制对象,可禁用复制构造函数和复制赋值构造函数。注意default仅适用于特殊成员函数,而delete则可用于任何成员函数。如

class Test {
public:
	Test() = default;
	Test(const Test &) = delete;
	Test & operator=(const Test &) = delete;
}

要禁止复制,还可以将复制构造函数和赋值运算符放在private部分,但是使用delete则会更容易理解
delete的另外一个用法是禁止特定的转换。如下面程序,若仅有参数类型为double的函数,func(5)则会将5转换为5.0再调用。但是此时检测到func(int)被禁用后则会将func(5)当做编译错误

class Test {
public:
	void func(double);
	void func(int) = delete;
}

构造函数

构造函数专门用于构造新对象,将值赋给它们的数据成员。结构函数的名称与类名相同,没有返回值以及声明类型,位于类声明的公有部分

调用构造函数

//显示调用
Stock food = Stock("World Cabbage", 250, 1.25);
//隐式调用
Stock garment("Furry Mason", 50, 2.5);
//new创建
Stock *pstock = new Stock("Electroshock Games", 18, 19);

//也可以用列表初始化
Stock a1 = {"A1", 1, 1.1};
Stock a2 {"A1", 1, 1.1};
Stock a3 {};

//另外,当构造函数只有一个形参时允许直接使用等号进行赋值
Stock tmp = 1;

C++中的explicit关键字只能用于修饰只有一个参数或只有第一个参数没有默认参数的类构造函数,它的作用是表明该构造函数是显式的不能进行隐式类型转换,跟它相对应的另一个关键字是implicit。类构造函数默认情况下即声明为implicit。如上面的Stock tmp = 1;就有从int到Stock的隐式转换,在构造函数名前加上explicit关键字后则禁止了这种转换,此时要只能进行显式转换Stock tmp = (Stock)1;

默认构造函数是指未提供显式初始值时用来创建对象的构造函数。默认构造函数定义方式有两种,一种是不接受任何参数,另一种是所有参数都提供默认值。当没有提供任何构造函数时C++会自动提供默认构造函数,它是默认构造函数的隐式版本。但当提供有非默认构造函数时则不会自动提供默认构造函数,此时直接定义对象而不赋予初始值则会发生错误

Stock first("Concrete Conglomerate");//调用非默认构造函数
Stock second();//声明无参数且返回值为Stock类型的函数
Stock third;//调用默认构造函数

注意构造函数也能被声明为protected或private,此时外部就不能创建这个类实例或者从这个类派生(当声明为protected时则能够派生)。因为只能从内部访问到类的构造函数,因而此时只能通过static成员函数或者友元来创建类对象,同时,还能在创建时进行某些限定,如限定程序中最多只能创建特定数量的类对象

在C++11中,为了简化构造函数的编写,提供了委托构造函数和继承构造函数。委托构造函数是指在一个构造函数的定义中使用另一个构造函数,因为构造函数暂时将创建对象的工作委托给另一个构造函数,如

class Notes {
public:
	Notes();
	Notes(int);
	Notes(int, double);
	Notes(int, double, string);
private:
	int k;
	double x;
	string st;
}
Notes::Notes(int _k, double _x, string _st) : k(_k), x(_x), st(_st) {};
Notes::Notes() : Notes(0, 0.01, "A") {};
Notes::Notes(int _k) : Notes(_k, 0.01, "A") {};
Notes::Notes(int _k, double _x) : Notes(_k, _x, "A") {};

而继承构造函数则能进一步简化编写工作。首先在C++98中允许这么编写成员函数。此时,using表明使用了A命名空间中的func函数以及其所有重载版本,此时在B中有三个func函数,但是double类型的版本是使用B中的版本。

class A {
    int func(int);
    double func(double);
    void func(const char* s);
}
class B : class A {
    using A::func;
    double func(double);
}

而在C++11中将这种方法用于构造函数,让派生类继承基类的所有构造函数(默认构造函数、复制构造函数和移动构造函数除外),但不会使用与派生类构造函数的特征标匹配的构造函数。如下面程序,此时B tmp(1, 1.1);会调用基类A中对应的构造函数,但是此时只会给基类数据成员初始化

class A {
public:
    A();
    A(int);
    A(double);
    A(int, double);
}
class B : class A {
public:
    using A::A;
    B();
    B(int);
    B(double);
}

析构函数

析构函数一般用于delete在构造函数中使用new分配的内存空间。若构造函数中没有使用new则只需让编译器自动生成一个什么都不做的隐式析构函数即可。析构函数名称和构造函数一样,只需要再在函数名前加~

因为析构函数只有一个而构造函数可以有多个,所以当构造函数用到new时因当使多个构造函数都使用同样形式的new,以方便析构函数中统一delete

析构函数能显式调用,此时相当于调用一个普通的成员函数并不会真正销毁对象,而在类对象声明周期完结时还是会再隐式调用一次。若类的数据成员都在栈上时多次调用析构函数不会出现问题,而当有数据成员是存储在堆上时,多次调用析构函数则会多次调用里面的delete释放空间,造成错误
当类对象存储在栈上时,代码块运行结束则空间销毁,隐式调用析构函数。当类对象存储在堆上时,调用delete销毁空间则隐式调用析构函数。而类对象存储在定位new上(可以是堆或者栈)时销毁空间则不会调用其析构函数,因此这个为数不多的需要显示调用析构函数的例子

同构造函数一样,析构函数也能声明为private,此时可以使得类只能使用new动态地在堆上new一个新的类对象。 原因是C++是一个静态绑定的语言。在编译过程中,所有的非虚函数调用都必须分析完成。即使是虚函数,也需检查可访问性。因些,当在栈上生成对象时,对象会自动析构,也就说析构函数必须可以访问。而堆上生成对象,由于析构时机由程序员控制,所以不一定需要析构函数。此时,由于析构函数私有,所以直接delete系统不能调用其析构函数,所以还需要增加一个成员函数来调用析构函数

复制构造函数

复制构造函数用于将一个对象以按值传递的方式复制到新创建的对象中,其一般形式为Class_name(const Class_name &);其调用方法有四种:

Date a1(a0);
Date a2 = a1;
Date a3 = Date(a2);
Date* a4 = new Date(a3);

若数据成员中含有指针类型时,则最好定义一个显式复制构造函数进行深复制

赋值运算符

和复制构造函数一样,默认赋值运算符是用浅复制的方式,因而当有指针类型的成员函数时最后重新重载=运算符

类的常量

this指针

this是一个指向当前对象的const指针,其值是当前对象的地址且不能自行修改。this指针只能在类的内部使用。通过this可以使用->来访问类的所有成员。另外,只有当对象被创建后this指针才有意义,因此不能再static成员函数中使用

this指针其实是成员函数的一个隐式形参,在调用成员函数时将对象的地址作为实参传递给this

类中常量的定义

与C语言同理,在C++类中定义的const常量的生命周期不是整个程序的运行时间,而是对象的声明周期(另外,const数据成员的初始化只能在构造函数中进行)。而类中数组的初始化需要用到的长度因此也不能用const常量来定义。此时有两种方法

//一是用static const常量,此时这些常量需要在类声明外定义
class A {
	public:
		static const int THREAD_NUM;
		static const int MEM_BLOCK_SIZE;
		static const int PORT;
};
const int A::THREAD_NUM = 100;
const int A::MEM_BLOCK_SIZE = 1024;
const int A::PORT = 8080;
//注意是static类型主要在类外初始化而不是const,并且此时需要加上const关键字而不用加static关键字
//二是用枚举常量。枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数
class A {
	public:
		enum {
			THREAD_NUM = 100,
			MEM_BLOCK_SIZE = 1024,
			PORT = 8080
      };
  };

另外,和C语言一样,C++类中也不能定义两个含相同枚举元素的枚举量,此时C++提供了一种新的枚举量,其作用域为类,如

enum class egg {Small, Medium, Large, Jumbo};
enum class shirt {Small, Medium, Large, Xlarge};
egg choice = egg::Large;
shirt Jack = shirt::Large;

//也可以这样
enum egg {Small, Medium, Large, Jumbo};//此时能隐式转换成int类型
enum class shirt {Small, Medium, Large, Xlarge};//不能隐式转换成int类型,用于与int型数据比较会报错
egg choice = Large;
shirt Jack = shirt::Large;

//指定底层类型为short类型
enum class :short shirt {Small, Medium, Large, Xlarge};

const常量的初始化

类中const以及引用类型数据成员的初始化必须要在构造函数形参列表后,花括号前用冒号来初始化,这种语法叫作成员初始化列表。此时,数据成员初始化的顺序与它们出现在类声明中的顺序相同,而与成员初始化列表的顺序无关。如

class A {
public:
	A(int& c): _b(2), _c(c) {
		_a = 1;
	}
private:
	int _a;
	const int _b;
	int& _c;
};

注意创建对象时是先执行构造函数冒号后面的内容再执行构造函数里面的内容,且构造函数里面的初始化并不是真正意义上的初始化,而是先由声明提前创建好空间然后再在构造函数中进行赋值,所以不能直接在构造函数中初始化const常量以及引用变量,只有冒号后面的内容才是真正的初始化。此时必须在冒号后初始化这些数据成员

const成员函数

const成员函数有两种写法,其中的const真正修饰的是隐含参数this指针

  • void fun(int a,int b) const{}
  • void const fun(int a,int b){}

const成员函数有两种功能

  • 声明不能在调用此成员函数修改数据成员的值,但是这只能保证函数不能修改数据成员的值,但是仍能修改指针指向的内存空间的值
  • 使得 const 类对象可以调用 const 成员函数,而限定不能调用非const修饰的函数

同时,const成员函数可以被具有同样函数名以及形参列表的成员函数重载,此时const类对象只能调用const版本而非const类对象则能调用两个版本但一般都会调用非const版本,两者的本质区别在于类中所有对象都会自动增加一个this指针形参,而const版本的是const指针而非const版本的是非const指针(虽然const到非const不算重载,但在类里面合法)

static成员函数

静态成员函数需要在函数声明时加static关键字。此时不同通过对象调用静态成员函数,同理也不能使用this指针,此时在外部调用静态成员函数时需要用类名加作用域解析运算符来调用。静态成员函数只能使用静态数据成员

返回const对象

  1. 返回const对象的引用。使用const引用对象通常是为了提高效率。直接返回对象将会调用复制构造函数,而返回引用不会
  2. 返回非const对象的引用。两种常见的返回非const对象引用的情况是重载赋值运算符以及重载<<运算符
  3. 返回const对象。如为类重载了+运算符,此时C=A+B和A+B=C都是合法的,但是后者是将C赋值给A+B返回的临时变量,要想禁止这种情况出现则需要返回const对象

运算符重载

运算符重载不能重载基本数据类型的运算,若要对基本数据类型进行运算符重载,也要将其放在结构体、类或者枚举类型中再进行重载。下面所讲的运算符重载均以类对象为例

对类进行运算符重载时,函数可以放在类声明内部也可以放在外部,如下

Box operator+(const Box& a); //内部,注意此时算术式的左操作数才是调用对象,所以算术式中第一个操作数为非类项只能在外部定义
Box operator+(const Box& a, const Box& b); //外部,一般将二目运算符重载函数放在外面定义并设置为友元函数,这会让程序更容易适应自动类型转换

重载限制

  • 重载运算符必须至少有一个操作数是用户自定义类型
  • 不能改变运算符原来的句法规则,如修改运算符操作数数目、修改运算符优先级等
  • 不能创建新的运算符
  • 可重载运算符:
    【C++】第四章 类_第1张图片
  • 不可重载运算符:
    成员访问运算符 .
    成员指针访问运算符 ., ->
    域运算符 ::
    长度运算符 sizeof
    条件运算符 ?:
    预处理符号 #

注意若想类对象能进行连续运算,则一些重载运算符函数最好返回对象的引用

强制转换类型运算符重载

上面有说当类含有只有一个参数的构造函数时能进行参数类型到类类型的隐式转换和显式转换。而当需要从类类型转换为其他类型时,则需要重载类型转换函数operator Typename();。转换类型函数没有返回值,没有参数。如operator int();,此时就能使用Stock tmp = 1; int a = tmp; int b = (int)tmp;。但是当类存在多个类型转换重载函数,如operator int();和operator double();时,有时会因为隐式转换而存在二义性,不知道应该转换为int还是double类型,此时仍可用显式转换解决

new和delete运算符重载

用new创建动态对象时,首先会使用operatoe new()为对象分配内存(通常用malloc),然后调用构造函数来初始化内存。相应的,调用delete运算符时,首先调用析构函数,然后调用operator delete()释放内存(通常用free)

使用了new和delete的内存分配系统是为了通用目的而设计的,但是在特殊的情形下并不能满足需要。最常见的改变分配系统的原因常常是出于效率考虑:

  1. 增加分配和归还的速度。创建和销毁一个特定的类的非常多的对象,以至于这个运算成了速度的瓶颈
  2. 堆碎片。分配不同大小的内存会在堆上产生很多碎片,以至于虽然内存可能还有,但由于都是碎片,找不到足够大的内存块来满足需要。通过为特定的类创建自己的内存分配器,可以确保这种情况不会发生。例如在嵌入式和实时系统里,程序可能必须在有限资源情况下运行很长时间,这样的系统就要求内存花费相同的时间且不允许出现堆内存耗尽或者出现很多碎片
  3. 检测运用上的错误。例如:new所得的内存,delete时却失败了,导致内存泄漏;在new所得内存上多次delete,导致不确定行为;数据"overruns”(写入点在分配内存区块尾端之后)或“underruns”(写入点在分配区块之前)。可以超额分配,然后在额外空间放置特定的byte patterns(签名)来进行检测
  4. 统计使用动态内存的信息
  5. 为了降低缺省内存管理器带来的空间额外开销
  6. 为了弥补缺省分配器中的非最佳齐位
  7. 为了将相关对象成簇集中。例如,为了将特定的某个数据结构在仪器使用,并且使用时缺页中断频率降至最低。new和delete的palcement版本有可能完成这样的集簇行为
  8. 获得非传统的行为。例如:分配和归还共享内存

重载全局的new和delete:

  1. 重载了全局的new和delete后将使默认的new和delete不能载被访问,甚至在这个重新定义里也不能调用它们
  2. 重载的new必须有一个size_t参数。这个参数由编译器产生并传递给我们,它是要分配的对象的长度。函数返回值为一个大于等于这个对象长度的指针
  3. operator new()的返回值是一个void*,而不是指向任何特定类型的指针。所做的是分配内存而不是完成一个对象建立,直到构造函数调用了才完成了对象的创建
  4. operator delete()的参数是一个指向由operator new()分配的内存的void*,而不是指向任何特定类型的指针。参数是一个void*是因为它是在调用析构函数后得到的指针。析构函数从存储单元里移去对象。operator delete()的返回类型是void
void *operator new(size_t sz) {
	void *m = malloc(sz);
	if(!m) puts("out of memory");
	return m;
}
void operator delete(void *m) {
	printf("Delete");
	free(m);
}
//使用printf()和puts()而不是iostream,是因为创建一个iostream对象时(像全局的cin/cout/cerr),它们调用new去分配内存。用printf()不会进入死锁状态,因为它不会调用new来初始化自身

对类重载new和delete:

  1. 为一个重载new和delete,尽管不必显式地使用static,但实际上仍在创建static成员函数
  2. 当编译器看到使用new创建自定义的类的对象时,它选择成员版本的operator new()而不是全局版本的new()。但如果要创建这个类的一个对象数组时,全局的operator new()就会被立即调用,用来为这个数组分配内存。当然可以通过为这个类重载运算符的数组版本,即operator new[]和operator delete[]来控制对象数组的内存分配
    3、使用继承时,重载了的类的new和delete不能自动继承使用
class Widget {
	int i[10];
public:
	Widget() {}
	~Widget() {}
	void* operator new(size_t sz) {
		cout<<"Widget::new: "<

注意事项:

  1. 当重载operator new()和operator delete()时,我们只是改变了原有内存分配方法(重载operator new()唯一需要做的就是返回一个足够大的内存块的指针)。编译器将用重载的new代替默认版本去分配内存并调用构造函数
  2. 除非必须,否则不要自己重载new和delete。因为可能会漏掉可移植性、齐位、线程安全等细节。必须时,可以借鉴使用一些开放源码的标准库(例如Boost程序库的Pool)
  3. 客户要求0 bytes时operator new()也得返回一个合法的指针。通常的处理方法是当申请0字节时,将它视为申请1-bytes
  4. 重写delete时,要保证“删除null指针永远安全”

友元

通常公有类成员是访问类的唯一途径,而C++提供了另一种形式的访问途径:友元。友元有友元函数、友元类、友元成员函数三种

友元函数

友元函数是定义在类外部,但有权访问类的所有私有成员和保护成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。友元函数通常用于外部定义重载类的运算符时访问类的内部数据

class Box {
public:
	//在类声明内部声明,在外部定义
	friend void printWidth(Box box);
	void setWidth(double wid);
private:
	double width;
};
void Box::setWidth(double wid) { width = wid; }
void printWidth(Box box) { cout << box.width <

重载<<运算符

//第一种版本
void operator<<(ostream& os, const Date& d) {
    os << d.month << " " << d.day << endl;
}
cout << data; //使用时直接用cout输出
//第二种版本
ostream& operator<<(ostream& os, const Date& d) {
    os << d.month << " " << d.day;
    return os;
}
cout << "Today is" << data << "!"; 
//第一个版本中不允许多个<<连用是因为cout << "Today is"后是先返回一个os引用,然后这个os引用再调用下一个<<运算符函数。因而要想连用只需要返回os引用就行了

重载<<运算符必须是Date类的友元函数,但并不一定是ostream类的友元函数

友元类

如在电视类和遥控器类中,两者属性并不具有包含关系,但是遥控器类需要访问到电视类的成员,因此此时需要用到友元类。做法是在电视类中声明遥控器类为友元类friend class Remote;。

友元成员函数

可以选择仅让特定的类成员成为另一个类的友元,此时需要用到友元成员函数。在电视类中逐一声明遥控器类的与友元成员函数,此时还要加上作用域解析运算符friend void Remote::set_chan(Tv & t, int c);。

双向友元关系

有时需要类A是类B的友元,类B也是类A的友元,此时只需要在两者类声明中各自声明另一个类是该类的友元类就可以了

继承

C++提供了比修改代码更好的办法来扩展和修改类,这种方法叫类继承,它能够从已有的类派生出新的类,而派生类继承了原有类(基类)的特征

继承的定义

继承定义的一般格式为class DerivedClassName : acess-lable BaseClassName {}
acess-lable是指继承的形式,共有如下三种。当不写继承的关键字时,类默认为私有继承,结构体默认为公有继承

  1. 公有继承 public:基类的公有变为派生类的公有,基类的保护变为派生类的保护,私有派生类不可访问。公有继承是一种is-a的关系,即派生类属于基类的一种情况
  2. 私有继承 private:基类的公有变为派生类的私有,基类的保护变为派生类的私有,基类的私有不可访问
  3. 保护继承 protect:基类的公有变为派生类的保护,基类的保护变为派生类的保护,基类的私有不可访问

构造函数

不管派生类以何种形式继承基类,派生类都不能访问基类的私有数据成员,只能通过基类的公有数据成员带来访问,因此派生类的构造函数必须用到基类的构造函数。又因类的构造函数实际上并不是真正意义上的初始化,所以必须用成员初始化列表来调用基类的构造函数,如下
DerivedClassName(…) : BaseClassName(…) {}

若不使用成员初始化列表来初始化基类,则会自动调用基类的默认构造函数

派生类和基类

基类的指针或引用能指向派生类的对象,但是只能调用其基类的函数。反之派生类的指针或引用不能指向基类对象
因此,用派生对象初始化基类类对象,用派生对象给基类对象赋值也是可以的。因为此时基类对象的复制构造函数以及=重载运算符函数的参数都是基类对象的引用,因此此时会发生隐式转换

而反过来,若要基类对象能转换到派生类对象,一种方法是在派生类对象中定义参数为基类类型的复制构造函数或者赋值函数

系统会先执行基类构造函数,然后再执行派生类构造函数;析构函数则相反,系统会先执行派生类构造函数,然后再执行基类构造函数(注意派生类不能继承基类的构造函数、析构函数以及赋值函数)

如果基类有用new,派生类也用new时,派生类中的重载=运算符函数需要显式调用基类的重载=运算符函数,如

Derive& operator=(const Derive& d) {
    ...
    Base::operator=(d);//以函数表示法的方式调用基类赋值函数
}

多态

多态能使同一个方法在派生类和基类中的行为是不同的,也就是方法的行为取决于调用该方法的对象。C++有两种多态方式:静态多态和动态多态

  • 静态多态也称为编译期间的多态,编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推断出要调用那个函数,如果有对应的函数就调用该函数,否则出现编译错误。静态多态有两种实现方式:函数重载(包括普通函数的重载和成员函数的重载),函数模板的使用
  • 动态多态即运行时的多态,也称为动态链接,或后期绑定。在程序执行期间判断所引用对象的实际类型,根据其实际类型调用相应的方法。实现方法是先将基类的成员函数声明为虚函数,然后在不同的派生类中实现不同的内容。并通过指向派生类对象的基类指针或引用来调用统一的接口

派生类和基类同名成员函数的关系有三种

  • 重载(overload):指函数名相同,但是形参列表个数、顺序、及类型不同。此时原函数和重载函数都在同一个作用域中
  • 重写(覆盖,override):指派生类重新定义基类的虚函数。此时原函数和重写函数作用域不同,但是函数名、参数、返回值都必须相同。另外,函数的访问修饰符可以不同,即虚函数是public,而在派生类中可重写为private。另外,只要基类的返回类型是指向基类类的指针或引用,则派生类的返回类型可以是指向派生类的指针或引用,覆盖的方法就可以改变返回类型。这样的类型称为协变返回类型,简称协变。C++还提供override关键字,在派生类函数参数列表后,花括号前添加该关键字则表明该函数重写基类函数,若函数原型没有对应基类的原函数而会报错,增加了安全性
  • 重定义(隐藏):相当于派生类和基类成员函数的重载。此时原函数和重定义函数不在同一个作用域,其函数名相同,返回值不同。若参数不同,则不论原函数有无 virtual 关键字,基类的函数将被隐藏;若参数相同,但是基类函数没有 virtual关键字,则基类的函数被隐藏。如果想在派生类中调用基类被隐藏的成员函数,需要用基类名加作用域运算符来调用

虚函数:在基类中在成员函数原型前添加关键字 virtual声明函数为虚函数,并告诉编译器不要静态链接到该函数。另外,C++还提供了final关键字,可以用其来限定类,声明该类不能被继承;或者是限定虚函数,声明该函数不能被重写

纯虚函数:若在基类中不想对虚函数给出有意义的实现,则需将函数声明为纯虚函数,如virtual int func() = 0;。而当基类具有至少一个纯虚函数时,称基类为抽象基类,此时:

  • 抽象基类不能建立对象;
  • 抽象基类不能作函数参数;
  • 抽象基类不能作函数返回类型;
  • 可以声明抽象基类的指针;
  • 可以声明抽象基类的引用。

虚析构函数:若基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时则只会调用基类的析构函数。此时派生类中申请的空间就得不到释放从而产生内存泄漏。因而需要用到虚析构函数来解决这个问题。因此通常即使不需要析构函数都应给基类提供一个虚析构函数

虚函数表:在内存中每个类的开头都有一个虚指针指向虚函数表,而虚函数表在编译时创建,存储着这个类的虚函数,关于虚函数:

  • 如果派生类没有新增任何虚函数,则派生类和基类共用一个虚函数表
  • 虚函数按照其声明顺序放于表中,基类的虚函数存储在派生类的虚函数前面
  • 覆盖的派生类函数被放到了虚表中原来基类虚函数的位置,没有被覆盖的函数则依旧
  • 当派生类继承自多个基类时,派生类会有多个虚指针,使得每个基类都有自己的虚表,而派生类的成员函数则被放到了第一个基类的表中(第一个基类是按照声明顺序来判断的)。这样是为了解决不同的基类指针指向同一个派生类实例时能够调用到实际的函数
  • 如果基类的private或是protected虚函数同样会存在于虚函数表中,因此可用访问虚函数表的方式来访问这些non-public的虚函数,这是不安全的(因为虚函数地址就存储在类的开头,因此通过类指针就能访问到虚函数表),如
class Base {
private:
	virtual void f() { cout << "Base::f" << endl; }
};

class Derive : public Base {};

typedef void(*Fun)(void);
//可以使用typedef来隐藏一些指向成员函数的复杂指针

int main() {
    Derive d;
    Fun pFun = (Fun)*((int*)*(int*)(&d));
    //(int*)(&d)为虚函数表地址
    pFun();
	return 0;
}

多继承

多继承是指一个派生类可以有多个基类。对于类可以继承的基类的数目,编译器没有语言强加的限制,但在一个给定派生列表中,一个基类只能出现一次。然而多继承容易让代码逻辑复杂、思路混乱,中小型项目中较少使用,后来的 Java、C#、PHP 等干脆取消了多继承。多继承的一般形式为class D : public A, private B, protected C {}

多继承的构造函数和单继承形式基本相同,一般写法为
D(形参列表) : A(实参列表), B(实参列表), C(实参列表) {}
此时多个基类构造函数的调用顺序取决于派生类继承基类的顺序,而与成员初始化列表中的调用顺序无关。对于析构函数则为其逆序

当多个基类中有同名的成员时,直接访问该成员会产生命名冲突,编译器不知道使用哪个基类的成员。此时需要在成员名字前面加上基类名和作用域解析符::以显式地指明到底使用哪个类的成员,消除二义性

多重继承

多继承是指一个派生类继承自多个基类,而多重继承是指派生类继承自另一个派生类,即有多层继承关系

在多重继承中,容易造成冲突的是菱形继承,即类B、C均继承自类A,而类D则继承自类B、C。此时类D则会存在命名冲突及冗余数据等问题。C++使用虚继承来解决这个问题,使得在派生类中只保留一份间接基类的成员,虚继承的一般形式为

class A {
protected:
    int m_a;
};

class B: virtual public A {  //虚继承
protected:
    int m_b;
};

class C: virtual public A {  //虚继承
protected:
    int m_c;
};

class D: public B, public C {
public:
    void seta(int a) { m_a = a; }
    void setb(int b) { m_b = b; }
    void setc(int c) { m_c = c; }
    void setd(int d) { m_d = d; }
private:
    int m_d;
};

此时派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。在实际开发中,将位于中间层次的基类继承声明为虚继承一般不会带来什么问题

有间接虚基类的派生类可以直接调用间接基类构造函数,但有间接非虚基类则不可以

当虚基类和非虚基类混用时,设类B为类C、D的虚基类,以及类X、Y的非虚基类,而类M则从C、D、X、Y派生而来,此时类M从虚派生祖先类C、D共同继承一个类B子对象,并从每个非虚派生祖先类X、Y分别继承了一个类B子对象。因而类M共包含三个类B子对象

另外,二义性通常发生在不同的派生路径之间,而同一条派生路径中派生类的成员优先级会比基类成员的优先级要高,此时会优先使用派生类的成员而不会产生二义性

类的嵌套定义

嵌套类

嵌套类或嵌套类型是指在一个类的内部定义另一个类。嵌套类只用于外围类的实现,且同时可以对用户隐藏该底层实现。外围类对嵌套类成员的访问没有任何特权,嵌套类对外围类成员的访问也是如此。在外部定义嵌套类时,需要加上作用域运算符

class A {
public:
	void func();
private:
	class B;
	B* m_b;//嵌套类只能声明嵌套类的指针和引用,定义为B m_b而不是B* m_b将会引发一个编译错误
};

当嵌套类是在外围类的保护部分或私有部分声明时嵌套类只能由外围类本身访问使用,而当嵌套类是在公有部分声明时则外部世界也是使用嵌套类,只是此时需要加上作用域解析运算符
另外,嵌套类可以直接引用外围类的静态成员、类型名和枚举成员(假定这些成员是公有的)(类型名是一个typedef名字、枚举类型名、一个类名、或模板中的类型参数)
若要想让嵌套类能够访问外围类,因为嵌套类的数据块存储在外围类的数据块中,因此只需要用嵌套类this指针减去外围类this指针得到偏移量就能在嵌套类内得到指向外围类的指针,进而就能访问并修改外围类了

局部类

局部类是指定义在函数体内的类。 局部类只在定义它的局部域内可见,与嵌套类不同的是,在定义该类的局部域外没有语法能够引用局部类的成员。注意局部类的成员函数必须被定义在类定义中,并且局部类中不能说明静态成员函数

void func() {
	static int s = 0;
	class Local {
		public:
		void init(int i) { s = i; }
	};
	Local m;
	m.init(10);
}

你可能感兴趣的:(C++)