本文总结C++面向对象的相关理论知识。

1 面向对象

1.1 面向对象的特征

面向对象的特征:封装、继承、多态。

  • 封装:把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。(优点:可以隐藏实现细节,使得代码模块化)
  • 继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。(优点:可以扩展已存在的代码模块(类))
  • 多态:一个类实例的相同方法在不同情形有不同表现形式。多态机制使具有不同内部结构的对象可以共享相同的外部接口。虽然针对不同对象的具体操作不同,但通过一个公共的类,这些操作可以通过相同的方式被调用。
    多态实现的两种方式:父类指针指向子类对象或将一个基类的引用类型赋值为它的派生类实例。(重要:虚函数 + 指针或引用)

1.5 类中的数据成员初始化顺序

一个类中的数据成员初始化的顺序,只与数据成员的定义时的顺序有关,而与初始化列表中数据成员的出现次序无关。

1.7 构造函数的性质

  • 构造函数的名字和类名相同
  • 构造函数没有返回类型
  • 构造函数不能被声明为const
  • 构造函数可以重载,一般包含默认构造函数,拷贝构造函数。
  • 默认构造函数不需要任何参数
  • 构造函数不能为虚函数

1.7.1 构造函数初始化列表

注意: 如果成员是const、引用或者属于某种未提供默认构造函数的类类型。我们必须通过构造函数初始化列表为这些成员提供初值。

数据成员初始化的顺序
数据成员初始化的顺序与它们在类中定义时出现的顺序一致,而与它们初始化的顺序无关。一般来说,初始化类成员没有严格的顺序要求。但是如果用一个成员初始化另一个成员时,顺序就很关键。例如:

1
2
3
4
5
6
7
class X{
	int i;
	int j;
public:
	//未定义的:i在j之前被初始化
	X(int val):j(val), i(j){}
};

上例中,从构造函数初始值的形式上看,好像是先用val初始化j,然后再用j初始化i。但实际上,i先被初始化,因此这个初始值的效果是试图使用未定义的j初始化i。

提示:最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且尽量避免使用某些成员初始化其他成员。

默认实参和构造函数: 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

1.7.2 构造函数初始化时必须采用列表初始化的情形:

  • const修饰的成员变量
  • 引用成员变量
  • 数据成员是某种未提供默认构造函数的类类型

1.7.3 默认构造函数

当对象默认初始化或者值初始化时,会自动调用默认构造函数。
默认初始化的情况:

  • 在块作用域内不使用任何初始值定义一个非静态变量或者数组时
  • 当一个类本身包含类类型的成员且使用合成的默认构造函数时
  • 当类类型的成员没有在构造函数初始值列表中显示初始化时

值初始化的情形:

  • 在数组初始化的过程中若提供的初始值少于数组的大小时
  • 不使用初始值顶一个局部静态变量时
  • 通过形式T()的表达式请求值初始化时,其中T为类型名

温馨提示
如果在一个类中定义构造函数,最好提供一个默认构造函数。

1.8 析构函数的性质

  • 析构函数不能重载
  • 析构函数可以为虚函数,基类的析构函数建议定义为虚函数。

基类析构函数使用虚函数的好处:当使用基类的指针保存派生类的对象时,我们在释放基类的指针时不仅会释放基类的成员,还会释放派生的的成员,避免造成内存泄露。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class ClxBase {
public:
    ClxBase() {};
    virtual ~ClxBase() { cout << "Output from the destructor of class ClxBase!" << endl;};
    virtual void DoSomething() { cout << "Do something in class ClxBase!" << endl; };
};

class ClxDerived : public ClxBase {
public:
    ClxDerived() {};
    ~ClxDerived() { cout << "Output from the destructor of class ClxDerived!" << endl; };
    void DoSomething() override { cout << "Do something in class ClxDerived!" << endl; }
};

int main()
{
    ClxBase *p =  new ClxDerived;
    p->DoSomething();
    delete p;
    return 0;
}

温馨提示
绝不在构造和析构过程中调用virtual函数

1.9 拷贝初始化发生的情形

  • 用等号(=)定义类类型的对象
  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类(struct)中的成员

1.10 拷贝构造和拷贝赋值运算符

拷贝构造函数的作用:使用一个存在的对象初始化一个未被初始化的对象。
拷贝赋值运算符的作用:使用一个存在对象去替换一个已经初始化的对象。

1.11 深拷贝与浅拷贝

浅拷贝,指的是在对象复制时,只对对象中的数据成员进行简单的赋值,默认拷贝构造函数执行的也是浅拷贝。大多情况下“浅拷贝”已经能很好地工作了,但是一旦对象存在动态成员,那么浅拷贝就会出问题。 “深拷贝”的情况下,对于对象中动态成员,就不能仅仅简单地赋值了,而应该重新动态分配空间。

在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如A=B。这时,如果B中有一个成员变量指针已经申请了内存,那A中的那个成员变量也指向同一块内存。这就出现了问题:当B把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。 深拷贝和浅拷贝可以简单理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。

1.12 基类和派生类

1.12.1 基类

温馨提示

基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

  派生类可以继承其基类的成员,然而当遇到与类型相关的操作时,派生类必须对其重新定义。即派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义。   基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数 。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。   成员函数如果没被声明为虚函数,则其解析过程发生在编译时。

1.12.2 定义派生类

派生类必须通过使用类派生列表明确指出它是从哪个基类继承而来的。
派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明。

1.12.2.1 派生类的声明

  派生类的声明与其它类差别不大,声明中包含类名但是不包含它的派生列表:

1
2
class Bulk_quote : public Quote;	//错误:派生列表不能出现在这里
class Bulk_quote;				  //正确:声明派生类的正确方式

1.12.2.2 派生类构造函数

  尽管在派生类对象中包含有基类继承而来的成员,但是派生类并不能直接初始化这些成员
  派生类也必须使用基类的构造函数来初始化它的基类部分。
  除非特别指出,否则派生类对象的基类部分会像数据成员一样执行默认初始化
  如果想使用其他的基类构造函数,我们需要一类名加圆括号内的实参列表的形式为构造函数提供初始值。
  这些实参将帮助编译器决定应该选用那个构造函数来初始化派生类对象的基础部分。例如:

1
2
3
//Bluk_quote的基类为Quote,在Bluk_quoute初始化时使用Quote的构造函数
Bluk_quote(const std::string& book, double p, std::size_t qty, double disc):
Quoute(book, p), min_qty(qty), discount(disc){}

温馨提示

首先初始化基类部分,然后按照声明的顺序依次初始化派生类的成员。

1.12.2.3 派生类初始化的顺序

  • 首先调用虚基类的构造函数
  • 然后调用基类的构造函数。若有多个基类,基类构造函数的初始化顺序与派生类的派生列表一致
  • 然后调用成员对象的构造函数
  • 最后调用对象的本身的构造函数。

1.12.2.4 派生类中的虚函数

  派生类经常覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
  因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象使用。而且我们也能将基类的指针或引用绑定到派生类对象的中的基类部分上。这种转换称为派生类到基类的类型转换。编译器会隐式地执行派生类到基类的转换。
  这种隐式特性意味着可以把派生类对象或者派生类对象的引用用在需要基类引用的地方。同样,我们也可以把派生类对象的指针用在需要基类指针的地方。

1.12.2.5 继承和静态成员

  如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员都只存在唯一的实例。

  静态成员遵循通用的访问控制规则,如果基类中的成员是private,则派生类无权访问它。假设静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它。

1.12.2.6 被用作基类的类

  如果我们想使用某个类作为基类,这该类必须已经定义而非仅仅声明。

1
2
3
class Quote;	//声明但未定义
//错误:Quote必须已经定义
class Bluk_quote : public Quote{...};

  这一规定的原因显而易见:派生类中包含基类并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。因此,一个类不能派生它本身。

1.12.2.7 防止继承的发生

  有时我们定义这样的类,不希望其他类继承它。C++11提供了一种防止继承发生的方法,即在类名后跟一个关键字final:

1
class NoDerived final{}; 	//NoDerived不能作为基类

1.13 C++多态特性

C++的多态性实现方式有:编译时多态(静态绑定)和运行时多态(动态绑定)。

  • 静态绑定:绑定的是对象的静态类型,依赖于对象的静态类型,发生在编译期。函数重载和模板实现的是静态绑定。
  • 动态绑定:绑定的是对象的动态类型,依赖于对象的动态类型,发生在运行期。虚函数实现的是动态绑定。
    virtual函数是动态绑定,non-virtual函数是静态绑定,缺省参数值也是静态绑定的。

1.13.1 多态性(polymorphism)

引用或指针的静态类型与动态类型不同这一事实是C++语言支持动态性的根本所在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型。
因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本。
判断的依据是引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。
对象的类型是确定不变的。通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。

1.13.2 动态绑定

只有在我们使用基类的引用或者指针调用基类的虚函数是才会发生动态绑定。

  1. 必须使用基类的指针或者引用;
  2. 基类中必须存在这个调用的虚函数。 参见1.5.4的示例程序。

1.13.3 静态绑定

当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来,称为静态绑定。
除了动态绑定外,其它情况均为静态绑定。

例如如下程序的输出结果为:B->1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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(int argc ,char* argv[]){
    B* p = new B;
    p->test();
return 0;
}

上述程序的执行过程是:首先执行new B,调用类B的默认构造函数创建类B的对象。然后类B类型的指针指向以上新建的对象。 而后指针p调用test()函数。因为类B继承至类A,而且类B没有test()函数。因此此时的test()函数是类A的test()函数。 又因为类B拥有与类A形参列表相同的函数func(),而且类A的func()函数为虚函数。因此类B的func()函数会覆盖类A的函数。 因此类B在执行test()函数时,调用的是类B的func()函数。但是因为缺省参数是静态绑定的,因此val的值是基类A中的默认参数。因此就有: “绝不重新定义继承而来的缺省参数(Never redefine function’s inherited default parameters value.)”
在构造函数中不要调用虚函数。在基类构造的时候,虚函数是非虚函数,不会走到派生类中,既是采用的静态绑定。 当我们构造一个子类的对象时,先调用基类的构造函数,构造子类中基类部分,子类还没有构造,还没有初始化,如果在基类的构造中调用虚函数,如果可以的话就是调用一个还没有被初始化的对象,那是很危险的。 在析构函数中也不要调用虚函数。在析构的时候会首先调用子类的析构函数,析构掉对象中的子类部分,然后在调用基类的析构函数析构基类部分。

1.13.4 虚函数的性质

类中除了构造函数,静态函数以外的函数都可以为虚函数。 派生类要想覆盖(override)基类的虚函数,必须与基类的虚函数拥有相同的函数参数列表。

  在C++语言中,当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。因为我们知道运行时才知道调用了哪个版本的虚函数,所以所有虚函数都必须有定义。因此,我们必须为每一个虚函数都提供定义,而不管它是否被用到。只是因为,编译器也无法确定到底会使用哪个虚函数。

对虚函数的调用可能在运行时才能被解析

  当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的哪一个。

1
2
3
4
Quoute base("0-201-82470-1", 50);
print_total(cout, base, 10);	//调用Quote::net_price
Bulk_quote derived("0-201-82470-1", 50, 5, 0.19);
print_total(cout, derived, 10);	//调用Bulk_quote:net_price

1.13.4.1 派生类中的虚函数

当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。
然而这么做并非必须,因为一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致
同样,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外, 当类的虚函数返回类型是类本身的指针或引用时,可以不遵循上述规则。例如,如果D由B派生得到,则基类的虚函数可以返回B*而派生类的对应函数可以返回D*。只不过这样的返回类型要求从D到B的类型转换是可访问的。

温馨提示

基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct B{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};
struct D1 : B{
    void f1(int) const override; //正确:f1与基类中的f1匹配
    void f2(int) override;       //错误:B没有形如f2(int)的函数
    void f3() override;          //错误:B中f3不是虚函数
    void f4() override;          //错误:B中没有名为f4的函数
}

因为只有虚函数才能被覆盖。所以编译器会拒绝D1的f3。
该函数不是B中的虚函数,因此它不能被覆盖。类似的,f4的声明也会发生错误,因为B中根本就没有名为f4的函数。
我们还能把某个函数指定为final,如果我们已经把函数定义为final,则之后任何尝试覆盖该函数的操作都将引发错误:

1
2
3
4
5
6
7
8
struct D2 : B{
	//从B继承f2()和f3(),覆盖f1(int)
  void f1(int) const final;
};
struct D3 : D2{
	void f2();			//正确:覆盖从间接基类B继承而来的f2
	void f1(int) const;	//错误:D2已经将f2声明为final
};

1.13.4.2 虚函数与默认实参

  和其他函数一样,虚函数也可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。   换句话说,如果我们通过基类引用指针 调用函数,则使用基类中定义的默认实参,即使实际运行的派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的与其不符。

1.13.4.3 回避虚函数的机制

在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的。例如:

1
2
//强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);

1.13.4.4 虚函数表

C++中虚函数使用虚函数表和虚函数表指针实现。
虚函数表(vtabel,virtual function table):虚函数表是编译器在编译阶段生成的。虚函数表中存放的是虚函数的指针。
编译器在每个含有虚函数的类中包含了一个虚函数表指针*__vptr。当创建一个该类的实例时,虚函数表指针将指向虚函数表。虚函数表存放在只读区(.rodata区)。

1.14 访问控制与继承

继承类成员的访问级别

成员权限(下)/ 继承权限(右) public protected private
public public protected private
protected protected protected private
private 不可访问 不可访问 不可访问