本篇隶属C++ Primer中类设计工具专题,当前集中于面向对象程序设计。 # 什么是面向对象编程? 在面向对象编程(缩写为OOP)中,重点是创建程序定义的数据类型,这些数据类型既包含属性,也包含一组定义明确的行为。OOP中的“对象”一词,是指我们可以从此类类型中实例化的对象。 面向对象编程的核心理念是:数据抽象,继承和动态绑定

继承

概述

基类:位于层次关系中的根部,定义所有类共同的成员变量和成员函数; 派生类:通过继承自动接收基类的成员函数和成员变量;可添加想要的其他函数或成员变量。

  • 类派生列表:明确指出当前派生类是从哪个基类继承而来,示例:
1
2
3
4
class Bulk_Quote : public Quote{	// Bulk_Quote继承Quote
public:
double net_price() const override;
}

定义基类和派生类

定义基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Quote{
public:
Quote()=default;
Quote(cosn std::string &book, double sales_price):bookNo(book),price(sales_price){}
std::string isbn() const {return bookNo;} // 返回给定书籍的销售总额

virtual double net_price(std::size_t n) const{ // 虚函数:派生类改写,使用不同的折扣计算方法
return n*price;
}
virtual ~Quote()=default; // 对析构函数进行动态绑定

private:
std::string bookNo;
protected:
double price=0.0;
}

为什么要将基类的析构函数定义为虚函数? 解答:当派生类中需要回收内存时,如果析构函数不是virtual,则不会触发动态绑定,只会调用基类的析构函数(而非子类的析构函数)那么:子类资源无法正确释放,导致内存泄露

成员函数

基类定义成员函数时,需区分以下两种情况: 1. 派生类直接继承、不要改变的函数;(编译时解析,执行与派生类细节无关)

  1. 派生类进行覆盖的函数:定义为虚函数
  • 运行时解析:使用基类指针/引用调用虚函数时,根据其绑定类型对象类型的不同,确定执行的版本。

访问权限

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

  1. 类的内部:无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。

  2. 类的外部:只能通过对象访问public属性的成员,不能访问private, protected属性的成员。

  3. 派生类:派生类可以访问基类的public, protected成员,不能访问private成员。

定义派生类

派生类构造函数

派生类首先使用基类的构造函数,初始化其从基类继承的部分;然后按照声明的顺序,依次初始化派生类的成员。

1
Bulk_quote(const std::string& book, double p, std::size_t qty, double disc):Quote(book, p),min_qty(qty), discount(disc){}

如上例,将前两个参数传递给Quote的构造函数,由其初始化Bulk_quote的基类部分(bookNoprice两个成员);再初始化由派生类直接定义的min_qtydiscount成员。

静态成员

如果基类定义了一个静态成员,则:整个继承体系中,只存在该成员的唯一定义;每个静态成员变量,只存在唯一实例。

派生类的声明

声明包含类名,不包括派生列表(派生列表应该在定义中出现):

1
2
class Buik_quote:public Quote;	// 错误
class Bulk_quote; // 正确
防止继承的发生:final
1
class NoDerived final {}	// NoDerived不能作为基类

类型转换与继承

不存在下行的隐式类型转换(但存在上行的类型转换)
  • 上行转换:把派⽣类的指针或引⽤,转换成基类表示
  • 下行转换:把基类的指针或引⽤,转换为派⽣类表示
1
2
3
Quote base;
Bulk_quote* bulkP=&base; // 错误
Bulk_quote& bulkRef=base; // 错误

即使基类指针绑定的是派生类对象,也不能执行从基类向派生类的转换:

1
2
3
Bulk_quote bulk;
Quote *itemP=&bulk; // 基类指针绑定派生类对象
Bulk_quote *bulkP=itemP; //错误:也不能执行基类向派生类的转换

对比两种C++强制类型转换:

  1. static_cast没有运行时类型检查
    • 上行转换(派生类转基类)安全
    • 下行转换(基类转派生类)不安全:没有动态检查
  2. dynamic_cast:下行转换时,具有类型检查(信息在虚函数中)的功能,⽐static_cast更安全。
上行的自动类型转换,只对指针或引用有效(对对象无效)

虚函数

虚函数性质

  1. 基类声明的虚函数,在所有派生类中均为虚函数;

  2. 派生类中重写虚函数时:派生类中形参类型与该函数在基类中形参必须严格匹配。

    加了override说明符但形参列表不同:编译器报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct B{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
    }
    struct D1:B{
    void f1(int) const override; //正确:与B中f1匹配
    void f2(int) override; // 错误:形参不匹配
    void f3() override; // 错误:f3不是虚函数
    }

    不加override说明符,函数名相同但形参列表不同:合法,不会覆盖基类中版本,视作两个独立的函数

  3. 基类中声明为final的函数,派生类中不能重写:

    1
    2
    3
    4
    5
    6
    7
    struct D2:B{
    void f1(int) const final;
    }
    struct D3:D2{
    void f2(); // 正确:覆盖从B中继承的虚函数
    void f1(int) const; // 错误:D2中f1已声明为final
    }
  4. 派生类强行调用基类中的虚函数版本:使用作用域运算符

    1
    2
    double undiscounted=baseP->Quote::net_price(42);
    // 在编译时解析:无论baseP绑定的对象类型是什么,强行调用Quote的net_price版本

虚函数的实现:虚函数表

  • 虚函数表:每个类(包括抽象类)都有⼀个虚表,其中包含了该类的虚函数的地址。

  • 虚指针:每个对象都包含⼀个指向其类的虚表的指针,这个指针被称为虚指针(vptr)。

当调⽤⼀个虚函数时,编译器会使⽤对象的虚指针查找虚表,并通过虚表中的函数地址来执⾏相应的虚函数。这就是为什么在运⾏时,可以根据实际对象类型来确定调⽤哪个函数的原因。

多态的实现

C++中的多态性通过虚函数(virtual function)和虚函数表(vtable)实现。多态性允许在基类类型的指针或引⽤上调⽤派⽣类对象的函数:调用虚函数时执行动态绑定:运行时解析,执行的函数版本与绑定对象匹配。

基类声明虚函数

使⽤ virtual 关键字,以便派⽣类重写这些函数。

1
2
3
4
5
6
class Shape {
public:
virtual void draw() const {
// 基类的默认实现
}
};
派生类重写虚函数

在派⽣类中重写基类中声明的虚函数,使⽤ override 关键字。

1
2
3
4
5
6
class Circle : public Shape {
public:
void draw() const override {
// 派⽣类的实现
}
};
使用基类指针或引用,指向派生类对象
1
Shape* shapePtr = new Circle();
调用虚函数:运行时解析
1
shapePtr->draw();

虚函数表:编译器在对象的内存布局中维护了⼀个虚函数表,其中存储了指向实际函数的指针。这个表在运⾏时⽤于动态查找调⽤的函数。

抽象基类:不能被实例化的类

类比Java中的abstrct类;纯虚函数类比Java中的abstract方法

抽象类是不能被实例化的类,其主要⽬的是:提供⼀个接⼝,供派⽣类继承和实现

抽象类中可以包含普通的成员函数、数据成员和构造函数,但它必须包含⾄少⼀个纯虚函数:即在声明中使⽤ virtual关键字,并赋予函数⼀个 = 0 的纯虚函数。

派⽣类必须实现抽象类中的纯虚函数,否则它们也会成为抽象类。

纯虚函数

通过与虚函数的对比来说明。

  1. 没有实现:纯虚函数没有函数体,只有函数声明,无默认实现。
    • 虚函数有实现:有函数声明和实现,即在基类中可以提供默认实现。
  2. 强制覆盖派⽣类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
    • 虚函数可选实现: 派⽣类可以选择是否覆盖虚函数。如果派⽣类没有提供实现,将使⽤基类的默认实现。
  3. 禁止实例化:包含虚函数的类(即抽象基类)无法实例化。
    • 虚函数的类允许实例化
  4. =0声明
    • 虚函数用virtual声明
  5. 为接口提供规范,供派生类具体实现。
1
2
3
4
5
6
7
8
9
class AbstractBase {
public:
// 纯虚函数,没有具体实现
virtual void pureVirtualFunction() = 0;
// 普通成员函数可以有具体实现
void commonFunction() {
// 具体实现
}
};

访问控制

protected成员

protected关键字,是介于publicprivate之间的产物。

  1. private成员的相似点:protectedprivate成员,对于类的用户均为不可访问;

  2. public成员的相似点:(基类的)protectedpublic成员,对于派生类的成员和友元可访问

  3. 派生类的成员和友元,只能通过派生类对象(不能通过基类对象),访问基类的protected成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Base{
    protected:
    int prot_mem;
    }
    class Sneaky:public Base{
    friend void clobber(Sneaky&); // 能访问Sneaky::prot_mem
    friend void clobber(Base&); // 不能访问Base::prot_mem
    }
    void clobber(Sneaky &s){s.j=s.prot_mem=0;} // 正确
    void clobber(Base &b){b.prot_mem=0;} // 错误

public, private, protected继承

派生类对继承而得的成员的访问权限受两个因素影响:

  1. 基类中该成员的访问说明符:影响派生类的成员和友元的访问权限

    派生类的成员和友元的访问权限,只与基类中访问说明符有关,不受派生列表中访问说明符的影响:如下例中:Priv_Derv中可访问prot_mem(尽管派生列表中为private

  2. 派生列表中的访问说明符:影响派生类用户(包括派生类的派生类)的访问权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Base{	// 基类
public:
void pub_mem();
protected:
int prot_mem;
private:
char priv_mem;
}
// 派生类
struct Pub_Derv:public Base{
int f() {return prot_mem;} //正确:派生类可访问基类的protected成员
}
struct Priv_Derv:private Base{
int f1() const {return prot_mem;} // 正确:当前派生类不受派生列表中private的影响
}

// 派生类的用户
Pub_Derv d1;
d1.pub_mem(); // 正确
Priv_Derv d2;
d2.pub_mem(); // 错误:受Priv_Derv派生列表中private的影响,不能访问

// 派生类的派生类
struct Derived_from_Public : public Pub_Derv{
int use_base() {return prot_mem;} // 正确
}
struct Derived_from_Private : public Priv_Derv{
int use_base() {return prot_mem;} // 错误:Base::prot_mem在Priv_Derv中是private的
}
  1. 可通过using改变派生类继承的个别成员的可访问性:

    • using出现在类的private部分:能被类的成员、友元访问;
    • using出现在类的protected部分:能被类的成员、友元和派生类访问;
    • using出现在类的public部分:能被类的成员、友元、派生类和用户访问;
    1
    2
    3
    4
    5
    6
    7
    8
    class Derived : private Base{
    // 使用private继承,所以默认情况下,继承来的pub_mem, prot_mem是Derived的private成员
    public:
    using Base::pub_mem();
    // 使用using后:Derived的用户、派生类均可访问pub_mem
    protected:
    using Base::prot_mem; // 使用using后:Derived的
    }

上行转换(派生类向基类)的可访问性

  1. 派生类的用户:当且仅当D对Bpublic继承,才能进行上行转换;
  2. 派生类D的成员和友元:无论D对B的继承方式,均可上行转换;
  3. 派生类D的派生类E,其成员和友元:当且仅当D对Bpublicprotected继承,才能上行转换。

友元与继承

  • 友元关系不能传递和继承:对于友元的基类和派生类,不具备特殊访问能力。

  • 每个类控制各自成员的访问权限:对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此。

1
2
3
4
5
6
7
8
9
class Base{
friend class Pal; // Base的友元
}
class Pal{
public:
int f(Base b) {return b.prot_mem()}; // 正确:Pal是Base的友元
int f2(Sneaky s) {return s.prot_mem;} // 正确:Pal可访问派生类中,从Base继承的部分
int f3(Sneaky s) {return s.j;} // 错误:Pal不可访问派生类中,不属于基类的部分
}

structclass默认继承保护级别

默认情况下,class定义的派生类采取private继承;struct定义的派生类采取public继承

继承中的类作用域

派生类的作用域,嵌套在其基类的作用域之内

  1. 如果一个名字在派生类的作用域内无法解析,则编译器向其外层基类中,不断递归寻找该名字定义,直到继承链的顶端。 > 逻辑类似于:编译器在符号表中查找标识符。

  2. 名字冲突:若出现同名,定义在派生类的名字,将隐藏定义在基类中的名字(即使参数列表不同,也会隐藏掉)

    可通过作用域运算符,使用隐藏的基类成员,例如:

    1
    2
    3
    struct Derived:Base{
    int get_base_num() {return Base::mem;}
    }

构造函数与拷贝控制

虚析构函数

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

delete一个动态分配对象的指针时,可能会出现:指针的静态类型与被删除对象的动态类型不符的情况(运行时多态)。因此需要定义为虚函数,实现虚构函数的动态绑定。

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针时,将产生未定义的行为。

1
2
3
4
5
6
7
8
9
class Quote{
public:
virtual ~Quote()=default;
}

Quote *itemP=new Quote;
delete itemP; // 调用Quote的析构函数
itemP=new Bulk_quote; // 静态类型与动态类型不一致
delete itemP; // 调用Bulk_quote的析构函数

合成拷贝控制

未完待续...

容器与继承

当希望在容器中存放具有继承关系的对象时,可采用智能指针

1
2
3
4
5
6
vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>(...));
basket.push_back(make_shared<Bulk_quote>(...));

cout<<basket.back()->net_price(15)<<endl;
// basket存放的是shared_ptr,必须解引用才能获得运行net_price的对象

如果定义容器中直接存放基类对象,那么添加派生类时,其派生类部分将会被忽略掉。