Skip to main content

Class

struct Sales_data {
public:
std::string isbn() const {
return bookNo;
}
Sales_data &combine(const Sales_data&);
double avg_price() const;
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0;
};

成员函数的声明必须在类的内部,定义可以在类的内部或外部。

类内成员函数可以显式声明 inline,也可以不声明、放到类外部再声明为 inline。类内部没有声明 inline,但定义在类内部的函数,是隐式的内联函数。

char get() const { return contents[cursor]; } // 隐式内联
inline char get(pos ht, pos wd) const; // 显式内联

编译器处理类内部的名字,分两步处理:

  1. 编译成员的声明。声明中使用的名字,包括返回类型、参数类型,都必须在使用前确保可见。
  2. 直到类全部可见后,才编译函数体。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。

this 指针

this 指针总是指向“这个”对象,是一个常量指针。默认情况下,this 的类型是指向类类型非常量版本的常量指针,即 Sales_data *const this,但是,只有 pointer to const 才能绑定到常量对象上,意味着,不能把 this 绑定到一个常量对象上,也就意味着,我们不能在一个常量对象上(例如 const Sales_data)调用普通的成员函数。例如:std::string isbn();

所以,把 this 设置为指向常量的指针,有助于提高函数的灵活性,毕竟,这个函数体里只读不写。C++ 的做法是把 const 关键字放在成员函数的参数列表之后,表示 this 是一个指向常量的指针,这样的成员函数被称为常量成员函数。std::string isbn() const;

定义返回 this 对象的函数:

Sales_data &Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this; // 返回调用该函数的对象
}

类本身就是一个作用域,使用作用域操作符告诉编译器,这个函数是在 Sales_data 作用域内的,这样,编译器就知道 revenue 指的是 Sales_data 的成员变量了。

double Sales_data::avg_price() const {
if (units_sold)
return revenue / units_sold;
else
return 0;
}

根据对象是否是 const 重载函数,函数调用会匹配相应的版本:

inline Screen &display(std::ostream &os) {
do_display(os);
return *this;
}

inline const Screen &display(std::ostream &os) const {
do_display(os);
return *this;
}

构造函数

类通过特殊的成员函数来控制其对象的初始化过程,称为构造函数。

构造函数的名字与类名相同,没有返回类型。

如果我们没有显式定义任何构造函数,编译器就会隐式地为我们定义一个默认构造函数。

和其他函数一样,如果构造函数体在类的内部,则是隐式内联的;如果在类的外部,则默认不是内联的。

struct Sales_data {
Sales_data() = default; // C++11 默认构造函数
Sales_data(const string &s) : bookNo(s) {} // 构造函数初始值列表,除了给数据成员赋值没有别的事情要做,因此函数体是空的
// 没有出现在构造函数初始值列表的成员,将通过类内初始值(如果有)初始化,或者执行默认初始化
Sales_data(const string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n) {}
Sales_data(istream &);
};

在类的外部定义构造函数:

Sales_data::Sales_data(std::istream &is) {
read(is, *this);
}

构造函数还有另一种写法,但不鼓励,我们应该始终使用初始值列表!如果没有在初始值列表中初始化成员,则该成员在执行构造函数体之前执行默认初始化。下面的写法,实际上是先将数据成员执行默认初始化,再给它们赋值。这一区别到底会有什么深层次的影响,完全取决于数据成员的类型。

Sales_data::Sales_data(const string &s, unsigned n, double p) {
bookNo = s;
units_sold = n;
revenue = p * n;
}

创建类的对象:

int main() {
Person noPerson; // 使用 Person 的默认构造函数初始化的对象
Person func(); // 注意:这是一个函数定义!不是一个默认构造函数初始化的对象!
Person person("James", "NY");
Person *ptr = &person;
cout << noPerson.getName() << endl;
cout << person.getName() << endl;
cout << ptr->getName() << endl;
return 0;
}

C++11 支持委托构造函数 (delegating constructor):

class Sales_data {
Sales_data() : Sales_data("", 0, 0) {}
Sales_data(string s) : Sales_data(s, 0, 0) {}
Sales_data(istream &is) : Sales_data() {
read(is, *this);
}
};

如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时把这种构造函数称为转换构造函数。

例如,Person 类有一个构造函数:Person(istream &is);,有一个函数 friend ostream &print(ostream &os, const Person &person);

print(cout, cin); 这段代码隐式地把 cin 转换成 Person。这个转换执行了 Person(istream &is) 构造函数,创建了一个临时对象,然后传递给 print 函数。

我们可以通过在构造函数声明加上 explicit 关键字,阻止这样的隐式转换。

编译器不会将 explicit 的构造函数用于隐式转换,但是我们仍然可以用这样的构造函数进行显式强制转换:print(cout, static_cast<Person>(cin));

类的其它特性

向前声明:

class Screen; // 向前声明
class Window_mgr {
std::vector<Screen> screens;
};

mutable data member,即使在 const 对象内也能被修改:mutable size_t access_ctr;

聚合类,当一个类满足以下条件时,我们说它是聚合的:

  • 所有成员都是 public
  • 没有定义任何构造函数
  • 没有类内初始值
  • 没有基类,也没有 virtual 函数

例如这样的类,是一个聚合类:

struct {
int ival;
string s;
}

数据成员都是字面值类型的聚合类是字面值常量类 (literal class),这是 C++11 的新特性。不是聚合类,但符合以下要求的,也是 literal class:

  • 数据成员都是 literal type
  • 类至少有一个 constexpr 构造函数
  • 内置类型数据成员的初始值(如果有的话)必须是 constexpr
  • 类类型数据成员的初始值(如果有的话)必须使用 constexpr 构造函数
  • 必须使用默认析构函数

友元

为非成员函数做 friend 声明,以让它访问类的私有成员。

friend istream &read(istream &is, Person &person);

友元类可以访问此类的所有成员。友元关系不存在传递性,Window_mgr 的友元不能访问 Screen 的私有成员。

class Screen {
friend class Window_mgr;
};

也可以只为别的类的某个函数提供访问权限:friend void Window_mgr::clear(ScreenIndex);

友元不是类的成员,不受类访问说明符的约束。

友元声明只能出现在类的内部。有的编译器还要求,在类内的友元声明之外,在类外部还需要再对函数专门进行一次声明。

友元声明只是指定了访问权限,并不是通常意义上的函数声明。

静态成员

静态数据成员一旦被定义,就存在于程序的整个生命周期中。

通常情况下,类的静态成员不应该在类内初始化。

class Account {
public:
void calculate() {
// 成员函数不用通过作用域运算符就可以访问静态成员
amount += amount * interestRate;
}
static double rate() {
return interestRate;
}
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};

在类的外部定义静态成员函数时,不能重复 static 关键字,static 关键字只出现在类内部的声明语句。

void Account::rate(double newRate) {
interestRate = newRate;
}
double Account::initRate() {
return 5.25 / 100;
}
double Account::interestRate = initRate();

使用作用域运算符直接访问静态成员。虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用、指针来访问。

int main() {
double r;
r = Account::rate();
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();
return 0;
}

拷贝控制

类有五种特殊的成员函数。这些操作统称为拷贝控制 (copy control)。

copy constructor, move constructor 定义了当用同类型的另一个对象初始化本对象时做什么。Foo(const Foo&);

copy-assignment operator, move-assignment operator 定义了当用同类型的另一个对象赋值给本对象时做什么。Foo& operator=(const Foo&);

destructor 定义了类销毁时做什么。~Foo()

默认情况下编译器会为我们合成拷贝构造函数。合成拷贝构造函数,会从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。

如果一个类需要析构函数,几乎可以肯定它同时也需要一个拷贝构造函数和拷贝赋值运算符。

使用 =default 显式使用编译器默认合成的函数。

使用 =delete 来阻止拷贝。

对象移动

C++ 11 一个最主要的新特性是可以移动而非拷贝对象的能力。在很多情况下会发生对象的拷贝,其中某些情况下例如函数返回、函数传参,对象拷贝后就立即被销毁了,在这些情况下,移动而非拷贝对象会大幅度提升性能。另外,还有一些对象根本就不支持拷贝。

为了支持移动操作,新标准定义了一种新的引用类型——右值引用 (rvalue reference),即必须绑定到右值的引用,且它只能绑定到一个将要销毁的对象。因此,我们可以将右值引用的资源“移动”到另一个对象中。

类似于“引用”,或者“左值引用”,右值引用也只不过是对象的另一个名字。不能将常规引用绑定到要求转换的表达式、字面常量、或返回右值的表达式,右值引用则完全相反,它可以绑定到这类表达式上,但不能绑定到左值上。

int i = 42;
int &r = i; // ✅ r 引用 i

int &r2 = i * 42; // ❌ 左值引用不能绑定右值
int &&rr2 = i * 42; // ✅ 右值引用可以绑定到右值
const int &cr = i * 42; // ✅ reference to const 可以绑定到右值

观察上面的例子得知,右值要么是字面常量、要么是表达式求值过程中创建的临时对象。

标准库 <utility> 头文件下定义了 move 函数,调用它来获得绑定到左值上的右值引用:

int &&rr1 = 42;
int &&rr2 = std::move(rr1);

move 不能使用 using 声明,而应该总是使用 std::move,避免潜在的命名冲突。

std::move 是一个 C++11 标准库中的函数模板,用于将一个对象的所有权转移到另一个对象。它实际上并不移动任何数据,而是将一个对象的指针或引用转移到另一个对象,从而避免了不必要的数据复制和内存分配。

下面是一个使用 std::move 的示例代码:

#include <iostream>
#include <vector>

int main() {
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1);

std::cout << "v1 size: " << v1.size() << std::endl; // 输出 0
std::cout << "v2 size: " << v2.size() << std::endl; // 输出 3

return 0;
}

在这个例子中,我们创建了两个 std::vector<int> 对象 v1v2,并将 v1 的所有权转移到 v2 中。使用 std::move 可以避免将 v1 中的数据复制到 v2 中,从而提高了代码的性能。

在这个例子中,v1.size() 输出 0 是因为在使用 std::movev1 的所有权转移到 v2 后,v1 中的元素已经被移动到了 v2 中,v1 变成了一个空的 std::vector<int> 对象。

当你使用 std::move 将一个对象的所有权转移到另一个对象时,原始对象的状态会被移动到新的对象中,原始对象的状态会变为未定义。在这个例子中,v1 的状态变为了未定义,因此调用 v1.size() 将返回一个未定义的值。

因此,在使用 std::move 时,你应该确保不再使用原始对象,并且不要假设原始对象的状态仍然有效。

面向对象程序设计

OOP 的核心思想是:数据抽象、继承、动态绑定。

数据抽象我们在第七章介绍过了,即类的接口与实现分离。

继承是类与类之间的层次关系。根部有一个基类 (base class),其它类直接或间接继承自基类,称为派生类 (derived class)。

C++ 中,基类希望其派生类进行覆盖的函数,声明为虚函数 (virtual),该函数在调用时动态绑定;另一种是基类希望派生类直接继承而不要改变的函数,没有声明 virtual 的成员函数,其解析发生在编译时而不是运行时。

// returns the total sales price for the specified number of items
// derived classes will override and apply different discount algorithms
virtual double net_price(std::size_t n) const { return n * price; }

可以将派生类对象当成基类对象来使用,也能将基类的指针或引用绑定到派生类对象上。基类的指针或引用的静态类型可能与其动态类型不一致。

Qoute item;
Bulk_qoute bulk;
Qoute *p = &item;
p = &bulk; // 编译器隐式执行派生类向基类的转换
Qoute &r = bulk; // 同上

C++11 的 override 关键字可以告诉编译器我们希望覆盖基类的虚函数,让代码的意图更加清晰、同时能让编译器为我们发现错误。

使用作用域运算符,强行调用基类中定义的函数,而不管动态类型:double undiscounted = baseP->Qoute::net_price(42)

就像友元关系不能传递一样,友元关系不能继承。当一个类将另一个类声明为友元时,只对做出声明的类有效,对其基类、派生类无效。

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

抽象基类

将函数声明为纯虚函数:double net_price(std::size_t) const = 0。一个纯虚函数无需定义。

含有纯虚函数的类是抽象基类。

访问控制

对于 struct,在第一个访问说明符之前的成员是 public 的;对于 class,则是 private 的。

别名一样存在访问限制,必须先定义后使用。

子类可以访问父类的 publicprotected 成员,不能访问父类的 private 成员。

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

class Bulk_qoute: public Qoute {}
// public 表示派生类从基类那里继承而来的成员是否对派生类的用户可见

默认情况下,class 定义的派生类是私有继承的;struct 定义的派生类是公有继承的。

classstruct 仅有的区别就是默认成员访问说明符、默认派生访问说明符。

构造、拷贝、移动、赋值、析构

派生类的构造函数,构造函数初始化列表将实参传给基类的构造函数:

Bulk_qoute(const std::string &book, double p, std::size_t qty, double disc) :
Qoute(book, p), min_qty(qty), discount(disc) {}

当执行派生类的构造、拷贝、移动、赋值操作时,首先构造、拷贝、移动、赋值基类部分,然后才轮到派生类部分。

析构函数的执行则相反,首先销毁派生类,然后执行基类的析构函数。

基类都应该定义一个 virtual 的析构函数,即使它不执行任何操作。原因是当我们 delete 一个动态分配对象的指针时,将执行析构函数。但由于动态绑定的存在,可能出现指针的静态类型与被删除对象的动态类型不符的情况。例如我们 delete 一个 Qoute* 类型的指针,但实际它指向的是 Bulk_qoute 对象,编译器必须清楚它应该执行 Bulk_qoute 的析构函数。和其它函数一样,我们通过在基类中将析构函数定义成虚函数,以确保执行正确的版本:

class Qoute {
public:
virtual ~Qoute() = default; // 动态绑定析构函数
}

容器与继承

当我们使用容器存放继承体系中的对象时,会遇到一个难题,将容器声明为 vector<Qoute>vector<Bulk_qoute> 都不合适。vector<Qoute> 在存放 Bulk_qoute 对象时,派生类的部分会被切掉;而 vector<Bulk_qoute> 无法存放 Qoute 对象,原因是基类无法隐式转换成派生类。

因此,当我们使用容器存放继承体系中的对象时,通常存放的是基类的指针(最佳选择是智能指针)vector<shared_ptr<Qoute>>

class Qoute {
public:
// virtual function to return a dynamically allocated copy of itself
virtual Quote* clone() const {
return new Quote(*this);
}
}

class Basket {
public:
void add_item(const Qoute& sale) {
items.insert(std::shared_ptr<Qoute>(sale.clone()));
}
}

模板与范型编程

模板是 C++ 中范型编程的基础。一个模板是告诉编译器如何创建一个类或者函数的蓝图。

函数模板

常规版本的 compare

int compare(const double &v1, const double &v2) {
if (v1 < v2) return -1;
if (v1 > v2) return 1;
return 0;
}

模板版本的 compare:关键字 template,模板参数列表 <typename T, ...> (template parameter list)。

template <typename T>
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v1 > v2) return 1;
return 0;
}

当我们调用一个函数模板时,编译器通常用实参来推断模板参数的类型。然后为我们实例化 (instantiate) 一个特定版本的函数。

T 称为类型参数 (type parameter),可以用来指定返回类型、函数的参数类型、函数体内变量声明或类型转换。类型参数前必须使用关键字 typenameclass,两者意义一样,可以互换使用。

template <typename T> T foo(T* p) {
T tmp = *p;
// ...
return tmp;
}

除了类型参数,还可以定义非类型参数 (nontype parameter),它可以是整型、指向(对象或函数)的(指针或引用)。

template <unsigned M, unsigned N>
int compare(const char &(p1)[M], const char &(p2)[N]) {
return strcmp(p1, p2);
}

当我们调用 compare("hi", "mom") 时,编译器会实例化成函数 int compare(const char (&p1)[3], const char &(p2)[4])

函数模板、类模板成员函数的定义通常放在头文件中。

类模板

template <typename T> class Blob {
public:
typedef T value_type;
void push_back(const T &t);
}

特例化

模板函数特例化

定义模板函数的特例化版本,实际上是接管了编译器的工作,为模板函数提供了一个特殊的实例。

关键字 template 后面接空尖括号,指出我们将为所有模板参数提供实参:

template <>
int compare(const char* const &p1, const char* const &p2) {
return strcmp(p1, p2);
}

模板类特例化

除了特例化函数模板,还可以特例化类模板。默认情况下,无序容器使用 hash<key_type> 来组织其元素。为了使我们自定义的 Sales_data 也能保存在无序容器中,必须特例化 hash 类,它必须定义:

  • 重载的调用运算符
  • 两个类型成员 result_typeargument_type
  • 默认构造函数、拷贝赋值运算符(可以隐式定义)

要注意的是,必须在 std::hash 类模板定义所在的命名空间中特例化它。

// 打开 std 命名空间,以便特例化 std::hash
#include "Sales_data.h"
using std::hash;

namespace std {
template <>
struct hash<Sales_data> {
typedef size_t result_type;
typedef Sales_data argument_type;
size_t operator()(const Sales_data &s) const;
};

size_t hash<Sales_data>::operator()(const Sales_data &s) const {
return hash<string>()(s.bookNo) ^
hash<unsigned>()(s.units_sold) ^
hash<double>()(s.revenue);
}
}

由于使用了 Sales_data 的私有成员,还需在 Sales_data 里声明此类为友元:

template <class T> class std::hash;
class Sales_data {
friend class std::hash<Sales_data>;
};

当我们使用无序容器存放 Sales_data 时,就会组合使用上面的 Sales_data 的特例化 hash 版本,以及 Sales_data 里定义的 == 运算符。

多重继承

C++ 的某些特性特别适合于处理超大规模问题,这些问题往往需要一个大团队工作数年才能解决。

多重继承即一个派生类从多个直接基类继承。派生类包含每个基类对应的基类部分。

如果一个类从多个基类直接继承,有可能这些基类本身又共享了另一个基类,在这种情况下,中间类可以选择虚继承,从而声明愿意与层次中的其他类共享虚基类。这样,派生类中只有一个共享虚基类的副本。

class Raccoon: virtual public ZooAnimal {};
class Bear: virtual public ZooAnimal {};
class Panda: public Bear, public Raccoon, public Endangered {};