C++面向对象程序设计

基类中类型相关的函数与派生类不做改变直接继承的函数是不同的,当希望派生类定义合适自身的版本,此时基类就将这些函数声明为虚函数。

基类

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Quote {
public:
Quote() = default;
Quote(const 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;
};

成员函数与继承

基类有两种成员函数,

  1. 希望派生类进行覆盖的
    • 当使用指针或引用调用虚函数时,该调用将被动态绑定
    • 任何除构造函数之外的非静态函数都可以是虚函数
    • 如果某个函数在基类中被声明为虚函数,那么在派生类中隐式地也是虚函数
  2. 希望派生类直接继承而不需要改变的
    • 如果不是虚函数,则其解析过程发生在编译时而非运行时

访问控制与继承

派生类能访问公有成员和受保护的成员,但不能访问私有成员(继承了,但无权限访问)。

派生类

类派生列表中的访问说明是控制派生类从基类继承而来的成员是否对派生类的用户可见。如果一个派生类是公有的,则基类的公有成员也是派生类接口的组成部分。可以将公有派生类型的对象绑定到基类的引用或指针上。

1
2
3
4
5
6
7
8
9
10
class BulkQuote : public Quote {
public:
BulkQuote() = default;
BulkQuote(const std::string&, double, std::size_t, double);
double net_price(std::size_t) const override;

private:
std::size_t min_qty = 0;
double discount = 0.0;
};

派生类中的虚函数

派生类经常,但不总是,覆盖继承的虚函数。如果没有覆盖,则虚函数行为类似其他的普通成员,直接使用基类中的版本。

派生类必须对需要覆盖的重新声明,派生类可以在这样的函数前加上virtual,但并非必须。C++11允许显式地在声明的最后override注明覆盖。

派生类向基类的类型转换

一个派生类对象中,继承自基类的部分和派生类的部分不一定是连续存储的

由于派生类对象中含有与其基类对应的组成部分,因此能将派生类对象当成基类对象来使用,能将基类指针或引用绑定到派生类对象中基类的部分上。这叫做派生类到基类的类型转换

由于一个基类对象可能是派生类对象的一部分,也可能不是,因此不存在从基类到派生类的自动类型转换,即使一个基类的指针或引用已经绑定在一个派生类对象上。

1
2
3
4
5
Quote item;
BulkQuote bulk;
Quote *p = &item;
p = &bulk;
Quote &r = bulk;

派生类构造函数

每个类控制它自己的成员初始化过程。派生类并不能直接初始化继承自基类的成员。初始化时,先初始化基类的部分,然后按照声明的顺序(与初始化列表顺序无关)依次初始化派生类的成员。

如果不明确指明初始化,派生类的基类部分会像数据成员一样执行默认初始化

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

每个类负责定义各自的接口,要想与类对象交互,必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。因此应当调用基类的构造函数初始化从基类继承而来的成员。

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

继承与静态成员

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

派生类的声明

派生类的声明不能包含派生列表。

被用作基类的类

某个类要用作基类,必须已经定义,而非仅仅声明。因为派生类中,要使用继承自基类的成员,派生类必须知道他们是什么。

防止继承可以使用final

静态类型和动态类型

变量或表达式的静态类型:编译时总是已知的,变量声明时类型或表达式生成的类型。动态类型:变量或表达式表示的内存中对象的类型,运行时才可知。

基类指针或引用的静态类型可能与其动态类型不一致

1
2
3
4
5
6
double print_total(ostream &os, const Quote &item, size_t n) {
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() << "# sold: " << n << " total due: " << ret
<< endl;
return ret;
}

上面item的静态类型是Quote&,动态类型直到运行时调用函数时才会知道。如果传入的是BulkQuote,则动态类型是BulkQuote。

由于一个基类对象可能是派生类对象的一部分,也可能不是,因此不存在从基类到派生类的自动类型转换,即使一个基类的指针或引用已经绑定在一个派生类对象上。如果需要转换,

  • 如果基类中有一个或多个虚函数,则可以使用dynamic_cast请求一个转换,该转换的安全检查将在运行时执行
  • 如果已知转换时安全的,则可以使用static_cast强制覆盖编译器的检查
1
2
3
4
5
BulkQuote bulk("awqef", 12, 23, 0.5);
Quote *pq = &bulk;
BulkQuote *pb = pq; // 错误
BulkQuote *pb1 = dynamic_cast<BulkQuote *>(pq);
BulkQuote *pb2 = static_cast<BulkQuote *>(pq);

派生类类型到基类类型的转换

派生类类型到基类类型的转换是不存在的。但可以向基类的拷贝/移动操作传递一个派生类对象,实际运行构造/赋值的运算符将是基类中定义的,此时只能处理基类自己的成员,忽略派生类定义的成员,派生类的部分被slice down

虚函数

由于运行时才知道调用了哪个版本的虚函数,因此所有虚函数必须有定义。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那个

动态绑定只有当通过指针或引用调用虚函数时才会发生,只有在这种情况下对象的静态类型才可能会与动态类型不同。

C++多态

C++中的引用或指针的静态类型与动态类型不同,是C++支持多态性的根本所在。

当通过基类的指针或引用调用基类中的一个函数时,如果是虚函数,则运行时才会依据所绑定对象的真实类型(动态类型)来决定到底执行哪个版本。如果不是虚函数,则解析过程发生在编译时而非运行时。类似的,通过对象进行的函数调用(虚函数或非虚函数)也在编译时绑定。

派生类中的虚函数

一旦某个函数被声明为虚函数,则在所有派生类中都是虚函数。

一个派生类的函数如果覆盖类某个继承而来的虚函数,这它的形参类型必须与被它覆盖的基类函数完全一致。同时,返回值也必须相同。但有下述例外,

1
2
3
4
5
6
7
8
9
10
11
class B {
public:
// ...
virtual B* f();
}

class D : public B {
public:
// ...
D* f();
}

如果虚函数的返回类型是类本身的指针或引用时,返回值可以不同,但要求从D到B的类型转换是可访问的

final和override

  • 如果派生类中的函数与虚函数名字相同形参列表不同(返回值都ok),那么这是合法的,但这个函数与原有虚函数相互独立,并未覆盖
  • 如果派生类中的函数与虚函数名字相同形参列表相同返回值不同,那么这是错误。
1
2
3
4
5
6
class D : public Quote {
public:
D();
int net_price(size_t n) const { return n * price; } // 错误
double net_price(double n) const { return n * price; } // ok,但与原有虚函数相互独立
};

为了避免出现上述的double net_price(double n) const,可以使用override来说明派生类中的虚函数。

只有基类出现过的虚函数,且派生类中的函数声明与虚函数一致时,才能override,否则就会报错。 如果某个函数被指定为final,则之后的任何尝试覆盖该函数的操作都将引发错误。

final和override出现在形参列表(包括const和引用修饰符 )和位置返回类型**之后。

虚函数与默认实参

如果某次函数调用使用了默认实参,则该实参值有本次调用的静态类型(基类中定义默认实参)决定。

回避虚函数机制

有时需要强制执行虚函数某个特定的版本,而不进行动态绑定。这时可以使用作用域运算符,

1
double undiscounted = baseP->Quote::net_price(42);

调用将在编译时完成解析。

通常只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制。 下面的调用中,派生类的虚函数调用了基类的版本,如果不回避虚函数机制,那么将会导致无限递归。

1
2
3
4
double D::net_price(double n) const {
Quote::net_price(n);
return n * price;
}

抽象基类

一个纯虚函数无须定义,其中=0只能出现在类内部的虚函数声明的语句处。但也可以为纯虚函数提供定义,不过函数体必须定义在类外部

含有(或者未经覆盖直接继承)的纯虚函数的类似抽象基类

  • 抽象基类负责定义接口,而后续的其他类可以覆盖这个接口
  • 不能直接创建一个抽象基类的对象
  • 如果派生类覆盖了纯虚函数,那么可以创建派生类的对象,否则不能。

派生类构造函数

派生类构造函数只能初始化它的直接基类

1
2
3
4
5
6
7
8
class BulkQuote1 : public DiscQuote {
public:
BulkQuote1() = default;
BulkQuote1(const std::string& book, double price, std::size_t qty,
double disc)
: DiscQuote(book, price, qty, disc) {}
double net_price(std::size_t) const override;
};

BulkQuote1包含三个子对象:空的BulkQuote1,DiscQuote和Quote。

访问控制和继承

派生类的成员或友元只能通过派生类对象来访问基类受保护成员(只能访问派生类对象中的基类部分的受保护成员),派生类对于一个普通的基类对象中的受保护成员没有任何访问特权

public、private和protected继承

派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没有什么影响,这是由基类中的访问说明符决定的。

派生访问说明符控制了派生类用户对于基类成员的访问权限

1
2
3
4
5
6
7
8
9
10
11
12
class B {
public:
void pub_mem();
}

class PrivD : private B {
public:
int f();
}

PrivD d;
d.pub_mem(); // 错误
  • public继承,成员将遵循原有的访问说明符
  • private继承,B的成员是私有的
  • protected继承,B的成员是受保护的

继承自派生类(上面的PrivD)的新类,基类(B)成员的访问权限由派生类(PrivD)的访问说明符决定。即如果是private的,那么新类人不能访问基类(B)的成员。

派生类向基类转换的可访问性

  • 对于用户代码,只有当Dpublic继承B,时,用户代码才能使用D向B的转换
  • 对于直接继承,无论是什么继承D的成员函数和友元都能使用D向B的转换
  • 对于间接继承,只有当Dpublic或protected继承B时,D的派生类的成员函数和友元才能使用D向B的转换

友元与继承

友元不能传递和继承。

改变成员的可访问性

可以用using将类的直接或间接基类中任何可访问成员(非私有)标记出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class B {
public:
void pub_mem();
private:
int prvi;
}

class PrivD : private B {
public:
using B::pub_mem();
using B::prvi; // 错误
int f();
}

PrivD d;
d.pub_mem(); // ok

默认继承保护级别

  • 派生类是struct,默认public继承
  • 派生类是class,默认是private继承

继承中的类作用域

类作用域

每个类都会定义自己的作用域。 Outside the class scope, ordinary data and function members may be accessed only through an object, a reference, or a pointer using a member access operator (§ 4.6, p.150). We access type members from the class using the scope operator. In either case, the name that follows the operator must be a member of the associated class.

定义在类外部的成员

一旦遇到类名,定义的剩余部分就在类的作用域之内(参数列表和函数体)。返回类型中使用的名字都位于类的作用域之外。

名字查找与类的作用域

类的定义分作,

  1. 编译类的声明
  2. 直到类全部可见后才编译函数体

这种两阶段处理的方式只适用于成员函数中使用的名字声明中使用的名字(包括返回值或参数列表中使用的名字),都必须确保在使用前可见

要注意的是,如果成员使用了外层作用域中的某个名字,且该名字代表一种类型,这内层作用域不能重定义这个名字。

1
2
3
4
5
6
7
8
typedef double M;
class A {
public:
M b();
private:
typedef double M; // 错误
M bal;
}

成员函数中使用的名字查找顺序,

  1. 成员函数内查找
  2. 类内查找
  3. 成员函数定义前的作用域内查找(包括类定义之前的全局作用域和成员函数定义前的全局作用域)

派生类的作用域

派生类的作用域嵌套在基类的作用域之内,如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找改名字的定义。

编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DiscQuote : public Quote {
public:
// ...
std::pair<std::size_t, double> discount_policy() const {
return {quantity, discount};
}
// ...
};

BulkQuote bulk;
BulkQuote *pbulk = &bulk;
Quote *pitem = &bulk;
pbulk->discount_policy(); // ok
pitem->discount_policy(); // 错误

这与动态绑定不同。

名字冲突与继承

如果派生类重用定义在其直接基类或间接基类中的名字,则定义在内层作用域(派生类)的名字将隐藏定义在外层作用域(基类)中的名字。

但可以使用作用域运算符覆盖原有的查找规则。一般来说,除了覆盖继承而来的虚函数外,派生类最好不要重用其他定义在基类中的名字。

名字查找先于类型检查

查找顺序,

  1. 先确定静态类型
  2. 在静态类型中查找member,找不到则顺着继承链向上找,最后没找到则报错
  3. 找到后检查函数调用是否合法
  4. 如果合法,
    • 是虚函数且通过引用或指针调用,编译器进行动态绑定
    • 不是,编译器产生一个常规函数调用

声明在内层作用域的函数并不会重载声明在外层作用域的函数,派生类中的函数也不会重载基类中的成员,而是隐藏基类的成员,即使它们的形参列表不一致

虚函数的作用域

假设形参列表不同,会发生隐藏,进而无法通过基类的引用或指针调用派生类的虚函数。

对应于之前提到的两个情况,

  • 如果派生类中的函数与虚函数名字相同形参列表不同(返回值都ok),那么这是合法的,此时隐藏了原有虚函数。
  • 如果派生类中的函数与虚函数名字相同形参列表相同返回值不同,那么这是错误,因为内层作用域中已经存在一个同名且同形参列表的函数了,不允许两个函数除返回值,其他要素都相同。

覆盖重载的函数

成员函数无论是否是虚函数都可被重载,派生类可以覆盖基类中重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么派生类就必须覆盖所有的版本,或者一个也不覆盖。(如果只覆盖部分,此时会隐藏基类的重载函数)

简便的方法是using + 名字(不需要形参列表)。需要保证基类函数的每个实例在派生类中都是可访问的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class B {
public:
virtual int fcn() {
cout << "base" << endl;
return 1;
}
};

class D : public B {
public:
using B::fcn; // 如果没有,则下面的d.fcn()是错误的
int fcn(int i) {
cout << "derive " << i << endl;
return 1;
}
};

D d;
d.fcn();
d.fcn(1);

构造函数与拷贝控制

如果一个类(基类或派生类)没有定义拷贝控制操作,则编译器会为它合成一个版本。

虚析构函数

由于基类的引用或指针指向继承体系中的某个类型,有可能出现指针的静态类型与被删除对象的动态类型不符的情况。因此需要将析构函数定义为虚函数,以确保执行正确的析构函数版本。

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为(只释放了基类部分的内存)。

基类的析构函数并不需要遵循三/五法则。虚析构函数(即使通过default的形式)将阻止合成移动操作

合成拷贝控制与继承

合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似,它们对类本身的成员依次进行初始化、赋值或销毁。这些合成的成员还负责使用基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁。

无论是基类的合成版本还是自定义版本,都有上述的行为,唯一的要求是新颖的成员是可访问的,且不是一个被删除的函数。

删除拷贝控制

  • 如果基类的构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的,则派生类中的相应成员也是被删除的
  • 如果基类的析构函数是不可访问或删除的,则派生类的默认和拷贝构造函数是被删除的
  • 如果派生类中显式请求编译器生成一个无法合成的移动操作,且基类中的对应操作是删除或不可访问的,派生类中的移动操作将是被删除的
  • 如果基类的析构函数是删除或不可访问的,则派生类中的移动构造函数将是被删除的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class B {
public:
B() = default;
B(const B &) = delete;
virtual ~B() = default;
};

class D : public B {};

B b;
b = b; // ok
b = std::move(b); // ok
B b1(b); // 错误
B b2(std::move(b)); // 错误

由于虚析构函数的存在,编译器不会合成移动操作,因此所有左值和右值的情况(即b = bb = std::move(b)B b1(b)B b2(std::move(b)))都将用拷贝操作处理。又因为拷贝构造函数是删除的,因此两个对拷贝构造函数的调用是错误的。

如果基类没有默认、拷贝或移动构造函数,则一般派生类也不会定义(可以,但必须考虑如何处理基类部分的成员)。

移动操作与继承

由于基类虚析构函数的存在,编译器不会合成移动操作。如果需要移动操作,则应该首先在基类中定义,与此同时必须显式地定义拷贝操作(否则会默认被定义为删除)。

派生类的拷贝控制成员

派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。而析构函数只负责销毁派生类自己分配的资源,派生类对象的基类部分是自动销毁的。

派生类的拷贝和构造函数

定义派生类的拷贝或移动构造函数时,通常使用对应基类的构造函数处理对象的基类部分。

如果未处理使用基类的拷贝或移动构造函数,则基类部分被默认初始化。

派生类赋值运算符

  • 与拷贝和移动构造函数一样,必须显式为基类部分赋值
  • 基类的运算符要能正确处理自赋值的情况
  • 基类运算符将释放左侧运算对象的基类部分的旧值

派生类析构函数

在析构函数执行完成以后,对象的成员会被隐式销毁,对象的基类部分也是隐式销毁的(基类的析构函数被自动调用执行),派生类只负责销毁由派生类自己分配的资源

对象的销毁顺序与创建顺序相反,派生类的析构函数先执行。

在构造函数和析构函数中调用虚函数

在构造或析构派生类对象的过程中,(从基类部分开始构造,派生类部分开始销毁)对象的类型就像是发生了改变一样。当前的构造函数或析构函数不能够调用未构造或已销毁的派生类版本的虚函数(可能会访问派生类部分的成员)。

如果构造函数或析构函数调用了某个虚函数,则应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

1
2
3
4
5
6
7
8
9
10
11
struct Erdos {
Erdos() { whoAmIReally(); }
virtual void whoAmIReally() { cout << "I really am Erdos\n"; }
};

struct Fermat : public Erdos {
virtual void whoAmIReally() { cout << "I really am Fermat\n"; }
};

Erdos s; // I really am Erdos
Fermat f; // I really am Erdos

上述代码中,并非是构造函数发生了继承。

继承的构造函数

这里的构造函数并非以常规的方式继承而来,且类不能继承默认、拷贝和移动构造函数。如果派生类含有自己的数据成员,则它们被默认初始化

1
2
3
4
5
class BulkQuote2 : public DiscQuote {
public:
using DiscQuote::DiscQuote;
// ...
};

上面的的using并不是令DiscQuote的构造函数在这里可见,而是令编译器生成一个与DiscQuote的构造函数对应的派生类构造函数,即

1
2
3
4
BulkQuote2(const std::string& book, double price, std::size_t qty,
double disc)
: DiscQuote(book, price, qty, disc) {
}

注意: * 构造函数的using声明并不会改变该构造函数的访问级别 * 一个using声明不能指定explicit或constexpr,基类的是什么,继承后的也是什么 * 当一个基类构造函数含有默认实参,这些实参并不会被继承,而是派生类会获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。

何时不继承

  • 如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数不会被继承
  • 不能继承默认、拷贝和移动构造函数,而是按照正常规则被合成。继承的构造函数不会被视为用户定义的构造函数(如果是,则不会合成默认构造函数),因此只包含继承的构造函数(无拷贝和移动构造函数)的类也会有合成的默认构造函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class C {
public:
C(const C &) = default;
};

C c; // 错误

class BulkQuote2 : public DiscQuote {
public:
using DiscQuote::DiscQuote;
// BulkQuote2(const BulkQuote2& b) : DiscQuote(b) {
// std::cout << "copy BulkQuote2" << std::endl;
// }
}

BulkQuote2 b; // ok

由于上面的BulkQuote2只有继承的构造函数,因此编译器会合成一个默认构造函数,故BulkQuote2 b正确。

容器与继承

1
2
3
4
5
6
7
vector<shared_ptr<Quote>> vq;
vq.push_back(make_shared<Quote>("joininnaw", 15));
vq.push_back(make_shared<BulkQuote>("awfawnn", 15, 10, 0.4));

for (const auto &q : vq) {
cout << q->net_price(20) << endl;
}

直接使用vector存储Quote对象是不行的,因为会使用Quote的拷贝构造函数,派生类对象的派生类部分会被截断

派生类的智能指针可以转换为基类的智能指针。