[C++ Primer] 第7章: 类
定义抽象数据类型
定义在类内部的函数是隐式的inline函数.
const成员函数
又叫做常量成员函数, 成员函数参数列表之后紧跟const关键字, const修饰的是类this指针.
默认情况下this的类型是指向类类型非常量版本的常量指针. 假如有一个名为MyClass的类, 在该类的普通成员函数中, this类型为MyClass *const类型, 即this本身不可改变, 但是可以改变this所指对象的成员变量. 对于该类的const成员函数, this类型为const MyClass *const类型, 即this本身不可改变, 其所指对象也不可改变.
常量对象, 以及常量对象的引用或指针都只能调用常量成员函数.
类的作用域和成员函数
编译器分两步处理类: 首先编译成员的声明, 然后才轮到函数体. 因此成员函数体可以随意使用类中的其他成员而无需在意这些成员出现的次序.
定义类相关的非成员函数
一般来说, 如果非成员函数是类接口的组成部分, 则这些函数的声明应该与类在同一个头文件中.
构造函数
构造函数不能声明成const的. 当我们创建类的一个const对象, 直到构造函数完成初始化过程, 对象才能真正获得其常量属性, 因此, 构造函数在const对象的构造过程中可以向其写值.
合成的默认构造函数
类通过一个特殊的构造函数来控制默认初始化过程, 这个函数叫做默认构造函数. 默认构造函数无需任何实参. 即如果一个构造函数没有任何实参, 则它是一个默认构造函数.
如果我们的类没有显式地定义构造函数, 那么编译器就会为我们隐式的定义一个默认构造函数. 编译器创建的构造函数又被称为合成的默认构造函数. 它将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值, 则用它来初始化成员
- 否则, 默认初始化.
某些类不能依赖于合成的默认构造函数
对于一个普通的类来说, 必须定义自己的默认构造函数, 原因有3点:
- 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数. 一旦我们定义了一些其他的构造函数, 那么除非我们再定义一个默认的构造函数, 否则类将没有默认构造函数. 只有当类没有声明任何构造函数时, 编译器才会自动地生成默认构造函数.
- 对于某些类来说, 合成的默认构造函数可能执行错误的操作. 因此, 含有内置类型或复合类型成员的类应该在类的内部初始化这些成员, 或者自己定义一个默认构造函数. 否则, 用户在创建类的对象时就可能得到未定义的值. 如果类包含有内置类型或者复合类型的成员, 则只有当这些成员全部被赋予了类内的初始值时, 这个类才适合使用合成的默认构造函数.
- 有时候编译器不能为某些类合成默认构造函数. 如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数, 那么编译器将无法初始化该成员.
= default的含义
class MyClass
{
public:
MyClass() = default; // 该构造函数不接受任何实参, 因此它是一个默认构造函数.
};
C++标准中, 如果需要默认行为, 可以通过在参数列表后面写上 = default 来要求编译器生成构造函数. 其中= default 既可以和声明一起出现在类内部, 也可以作为定义出现在类外部. 和其他函数一样, 如果= default 出现在类内部, 则默认构造函数是内联的, 如果它在类外部, 则该成员默认情况下不是内联的.
构造函数初始值列表
构造函数的执行顺序是先执行初始值列表初始化成员, 然后执行构造函数的函数体. 如果不能使用类内初始值, 则所有构造函数都应该显式地初始化每一个内置类型的成员.
拷贝, 赋值和析构
如果不主动定义这些操作, 编译器将替我们合成它们, 一般来说, 编译器生成的版本将对对象的每一个成员指向拷贝, 赋值和销毁操作.
当类需要分配类对象之外的资源时, 合成版本通常会失效. 但是, 很多需要动态内存的类能使用vector对象或者string对象来管理必要的存储空间. 使用vector或者string的类能避免分配和释放内存带来的复杂性.
如果类包含vector或者string成员, 则其拷贝, 赋值和销毁的合成版本能够正常工作.
访问控制与封装
一个类可以包含0个或多个访问说明符, 而且对于某个访问说明符能出现多少次也没有严格规定. 每个访问说明符指定了接下来的成员的访问级别, 其有效范围直到出现下一个访问说明符或者到达类的结尾处为止.
C++ 中使用struct和class定义类唯一的区别就是默认的访问权限.
友元
类可以允许其他类或者函数访问他的非公有成员, 方法是令其它类或者函数称为它的友元. 如果类想把一个函数作为它的友元, 只需要增加一条以friend关键字开始的函数声明即可.
class MyClass
{
friend void print(const MyClass&); // 友元声明
public:
// ...
private:
int a;
};
void print(const MyClass&); // 函数声明
友元声明只能出现在类定义的内部, 但是在类内出现的位置不限. 友元不是类的成员, 也不受访问控制符的限制. 一般来说, 最好在类定义的开始或者结束前的位置集中声明友元.
友元的声明仅仅指定了访问的权限, 而非一个通常意义上的函数声明. 如果我们希望类的用户能够调用某个友元函数, 就必须在友元声明之外再专门对函数进行一次声明.
友元不具有传递性, 每个类负责控制自己的友元类或友元函数. 比如A是B的友元, 同时C是A的友元, 则C并不能访问B.
类的其他特性
类型成员
定义类型成员
class Screen {
public:
typedef std::string::size_type pos; // 定义类型成员
using pos = std::string::size_type; // 等价的声明
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
内联函数
定义在类内部的函数自动是inline的. 也可以使用inline关键字修饰函数的声明或定义. 但是无须在声明和定义同时说明inline, 虽然这么做其实是合法的. 不过最好只在类外部定义的地方说明inline, 这样可以使类更容易理解.
和我们在头文件中定义inline函数的原因一样, inline成员函数也应该与相应的类定义在同一个头文件中.
可变数据成员
类中变量的声明前加入mutable关键字修饰的变量.一个可变数据成员永远不会是const, 即使它是const对象的成员. 因此一个const成员函数可以改变一个可变成员的值.
返回*this的成员函数
返回引用的函数是左值, 意味着这些函数返回的是对象本身而非对象的副本. 比如下面的代码:
class Screen {
public:
// ...
Screen &move(pos ht, pos wd);
private:
// ...
};
// 函数定义
inline Screen &Screen::move(pos ht, pos wd)
{
// ...
return *this;
}
// 函数调用
myScreen.move(4, 0).set('#');
move和set的返回类型均为Screen&, 返回*this即对象自身, 这些操作将在同一个对象上执行. 但是如果move和set返回的是Screen而非Screen&, 则其行为将大为不同. move将返回一个副本, set将在该副本上执行操作, 而不是在原对象上执行操作.
从const成员函数返回*this
一个const成员函数如果以引用返回*this, 那么它的返回类型将是常量引用.
class Screen {
public:
// ...
const Screen &display(std::ostream &os) const;
private:
// ...
};
类的前向声明
仅声明类而暂时不去定义它, 它向程序引入类名字并且指明它是一种类类型. 它是一种不完全类型, 即仅知道它是一个类类型, 但是不清楚它到底有那些成员.
不完全类型只能在非常有限的情况下使用, 可以定义指向这种类型的引用或者指针, 也可以声明(但不能定义)以不完全类型作为参数的或者返回类型的函数.
一个类的名字出现后, 它就被认为是声明过了, 因此类允许包含指向它自身类型的引用或指针.
友元再探
- 把普通的非成员函数定义成友元
- 把其他类定义成友元
- 把其他类的成员函数定义成友元
友元能定义在类的内部, 这样的函数是隐式内联的.
class Screen {
friend class Window_mgr;
};
如果一个类指定了友元类, 则友元类的成员函数可以访问此类包括的非公有成员在内的所有成员.
// 令成员函数作为友元 需要按照以下步骤:
// 1. 先定义Window_mgr类, 声明clear函数, 但是不能定义它, 因为clear作为Screen类的友元函数, 需要访问Screen类的成员变量, 因此要先定义了Screen类才能定义clear友元函数.
class Window_mgr {
public:
using ScreenIndex = std::vector<Screen>::size_type;
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
// 2. 定义Screen类, 包括对与clear的友元声明
class Screen {
friend void Window_mgr::clear(ScreenIndex);
};
// 3. 最后定义clear, 此时才乐意使用Screen的成员
void Window_mgr::clear(ScreenIndex i)
{
Screen &s = screens[i];
s.contens = string(s.height * s.width, ' ');
}
友元的声明和作用域
struct X {
friend void f() { /*友元可以定义在函数内部*/ }
X() { f(); } // 错误, f()还没有声明
void g();
void h();
};
void X::g()
{
return f(); // 错误, f()还没有声明
}
// 就算定义在类内部, 也必须在类外部提供声明从而使的函数可见
void f(); // 声明那个定义在X中的函数
void X::h() // 正确, 现在f()的声明在作用域中了
{
return f(); // 错误, f()还没有声明
}
但有些编译器并不强制执行上述关于友元的限定规则.
类的作用域
一旦遇到类名, 定义的剩余部分就在类的作用域之内了, 剩余部分包括参数列表和函数体.
函数的返回类型在函数名之前, 若返回类型是类中定义的类型, 则返回类型前也需要加上类名.
class Window_mgr {
public:
using ScreenIndex = std::vector<Screen>::size_type;
ScreenIndex addScreen(const Screen&);
private:
// ...
};
Window_mgr::ScreenIndex // 返回类型位于类作用域外, 因此要加域运算符
Window_mgr::addScreen(const Screen &s)
{
// 参数列表和函数体位于类作用域内, 可以直接使用类成员
// ...
}
类的定义分两步处理:
- 首先编译成员的声明
- 知道类全部可见后才编译函数体.
编译器处理完类中的全部声明后才会处理成员函数的定义.
这种两阶段的处理方式只适用于成员函数中使用的名字. 声明中使用的名字, 包括返回类型或者参数列表中使用的名字, 都必须在使用前确保可见.
类型别名的特殊处理:
类型名的定义通常出现在类的开始处, 这样就能确保所有使用该类型的成员都出现在类名的定义之后.
成员函数中使用的名字的查找过程:
- 首先在函数体内查找该名字的声明.
- 如果在成员函数内没找到, 则在类内部继续查找.
- 如果类内部也没有找到, 则在成员函数定义之前的作用域中查找.
int height;
class Screen
{
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) // 成员函数中的名字最好不要隐藏同名的成员
{
cursor = width * height; //用的是函数参数中的height
cursor = width * Screen::height;//用的是类成员的height
cursor = width * this->height; //用的是类成员的height
cursor = width * ::height; //用的是全局的height
}
private:
pos cursor = 0;
pos width = 0;
pos height = 0;
};
构造函数再探
初始化和赋值的区别事关底层效率问题: 前者直接初始化数据成员, 后者先初始化再赋值.
构造函数初始值列表: 初始值列表直接初始化, 而赋值则先要在函数体之前执行默认初始化, 然后在函数体内赋值, 这样效率很低下.
除了效率问题以外, 一些数据成员必须被初始化, 因此要养成使用构造函数初始值的习惯.
内置类型的初始化和赋值成本相同.
构造函数的初始值有时必不可少: 如果成员是const, 引用, 或者属于某种类类型且该类没有定义默认构造函数时, 必须通过构造函数初始值列表为这些成员提供初值.
class ConstRef{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int & ri;
};
// 初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值.
ConstRef:ConstRef(int ii)
{
i = ii; //正确
ci = ii; //错误, 不能给const赋值
ri = ii; //错误, ri没有被初始化
}
成员初始化顺序与它们在类定义中出现的顺序一致, 初始值列表只说明用于初始化成员的值, 而不限定初始化的具体执行顺序.
一般来说初始化的顺序没有什么特别要求, 但是如果一个成员使用另外一个成员来初始化的, 那么这两个成员的初始化顺序就很重要了.
最好令构造函数初始值的顺序与成员声明的顺序保持一致. 而且尽量避免使用某些成员初始化其他成员.
默认实参和构造函数:
class Sales_data {
public:
// 如果未提供实参, 则用默认的参数, 若提供了实参, 则用所提供的实参.
Sales_data(std::string s = ""): bookNo(s) { }
// ...
};
如果一个构造函数为所有参数都提供了默认实参, 则它实际上也定义了默认构造函数.
委托构造函数
C++11标准规定, 一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程, 或者说它把它自己的一些(或者全部)职责委托给了其他构造函数.
一个委托构造函数也有一个成员初始值的列表和一个函数体. 在委托构造函数内, 成员的初始值列表只有一个唯一的入口, 就是类名本身.
class Sales_data {
public:
// 非委托构造函数使用对用的实参初始化成员
Sales_data(std::string s, unsigned cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt * price) { }
// 其余构造函数全部委托给另一个构造函数
Sales_data(): Sales_data(" ", 0, 0) { } //委托给第一个构造函数
Sales_data(std::string s): Sales_data(s, 0, 0) { } //委托给第一个构造函数
Sales_data(std::istream &is): Sales_data() { read(is, *this) } //委托给第二个构造函数, 第二个构造函数有委托给第一个构造函数.
};
当一个构造函数委托给另一个构造函数时, 受委托的构造函数的初始值列表和函数体被依次执行, 然后控制权才会交还给委托者的函数体.
委托构造函数不能有初始化列表, 在C++中, 构造函数不能同时委派和使用初始值列表.
默认构造函数的作用
当对象被默认初始化或者值初始化时自动执行默认构造函数.
以下情况会执行默认初始化:
- 在块作用域内不使用任何初始值定义一个非静态变量或数组.
- 当一个类本身含有类类型的成员且使用合成的默认构造函数.
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时.
值初始化发生在以下情况:
- 数组初始化过程中提供的初始值数量少于数组的大小.
- 我们不使用初始值定义一个局部静态变量.
- 当我们通过书写形如T()的表达式显式地请求值初始化时.
类必须包含一个默认构造函数以便在上述情况下使用.
一个类中只能有一个默认构造函数, 若一个类中有两个构造函数, 一个不带参数, 一个的全部参数都有默认实参, 则这个类有两个默认构造函数. 若不使用默认构造函数去初始化一个对象, 则可以正常编译通过, 但是一旦用到默认构造函数, 则会产生一个二义性错误.
使用默认构造函数
Sales_data obj(); //定义了一个函数而非对象
Sales_data obj; //obj是个默认初始化的对象
想定义一个使用默认构造函数进行初始化的对象, 正确的方式是去掉对象名之后的空括号对.
隐式的类类型转换
如果构造函数只接受一个实参, 则它实际上定义了转换为此类类型的隐式转换机制. 有时我们把这种构造函数称为转换构造函数.
class A
{
public:
A(string s1 = ""): s(s1) {}
A& add(const A &rhs) // 参数是一个常量引用, 允许给该参数传递一个临时量, 非常量参数不允许接收临时量
{
s += rhs.s;
return *this;
}
const string &get_s() const { return s; }
private:
string s;
};
int main()
{
A aa("Hello ");
string s("World!");
aa.add(s); // 编译器用给定的string自动创建了一个临时A对象, 新生成的这个临时对象传递给add
// aa.add(" World!"); // Error, 只允许一步类型转换, 这里需要两步转换: const char* --> string --> A
aa.add(string(" world!")); // OK, 显示转换成string, 隐式转换成A
aa.add(A(" world!")); // OK, 隐式转换成string, 显示转换成A
printf("%s\n", aa.get_s().c_str());
return 0;
}
抑制构造函数定义的隐式转换
通过将构造函数声明为explicit可以组织隐式的转换.
关键字explicit只对一个实参的构造函数有效, 需要多个实参的构造函数不能用于执行隐式转换, 所以无需将这些构造函数指定为explicit的. 只能在类内部声明构造函数时使用explicit关键字, 在类的外部定义时不应重复.
explicit构造函数只能用于直接初始化.
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用 = ), 此时只能使用直接初始化而不能使用explicit构造函数.
class A
{
public:
explicit A(string s1 = ""): s(s1) {} // explicit只允许出现在类内部
A& add(const A &rhs)
{
s += rhs.s;
return *this;
}
const string &get_s() const { return s; }
private:
string s;
};
int main()
{
A aa("Hello "); // OK, 直接初始化
string s("World!");
A bb = s; // Error, 不能将explicit构造函数用于拷贝形式的初始化过程
aa.add(s); // Error, 接收string类型的A的构造函数是explicit的
aa.add(A("world!")); // OK, 实参是一个显示构造的A对象
aa.add(static_cast<A>("world!")); // OK, static_cast可以使用A的构造函数
printf("%s\n", aa.get_s().c_str());
return 0;
}
聚合类
聚合类使得用户可以直接访问其成员, 并且具有特殊的初始化语法形式.
- 所有的成员都是public.
- 没有定义任何构造函数.
- 没有类内初值.
- 没有基类, 也没有virtual函数.
可以提供一个花括号括起来的成员初始值列表, 并用它来初始化聚合类的数据成员. 初始值的顺序必须与声明的顺序一致. 即传统的C struct.
类的静态成员
静态成员与类本身直接想换, 而不是与类的各个对象保持关联.
声明静态成员
通过在成员的声明之前加上关键字static使其与类关联在一起. 静态成员可以是public或者private, 类型可以是常量, 引用, 指针, 类类型.
静态数据成员要在程序一开始运行时就必须存在, 因为函数在程序运行中被调用, 因此静态数据成员不能在任何函数内分配空间和初始化. 即使类的对象未被创建, 其静态成员也会被创建.
静态成员函数中不能引用任何非静态成员.
静态成员函数可以直接访问该类的静态数据成员. 而访问非静态数据成员, 必须通过参数传递方式得到对象名, 然后通过对象名来访问.
类中的任何成员函数都可以访问静态成员, 但静态成员函数只能通过对象名(或指向对象的指针)访问该对象的非静态成员, 因为静态成员函数没有this 指针.
不能通过类名来调用非静态的成员函数.
静态成员在使用前必须初始化.
类的静态成员存在于任何对象之外, 对象中不包含任何与静态数据成员有关的数据. 类似的, 静态成员也不与任何对象绑定在一起, 它们不包含this指针. 作为结果, 静态成员函数不能声明成const的, 而且也不能在static函数体内使用this指针.
类的静态成员的使用方法:
- 使用作用域运算符直接访问静态成员, 如 classname::staticmember
- 虽然静态成员不属于类的某个对象, 但是我们仍然可以用类的对象, 引用或指针来访问静态成员.
- 成员函数不用通过域运算符就能直接使用静态成员.
定义静态成员
static关键字只能出现在类内部的声明语句中.
静态成员不是由构造函数初始化的, 而且一般也不能在类的内部初始化静态成员. 相反必须在类的外部定义和初始化每个静态成员. 静态数据成员定义在任何函数之外, 因此一旦定义, 就将存在于程序的整个生命周期. 其定义方式为: 指定类型名, 然后是类名, 作用域运算符以及成员自己的名字.
class Account {
public:
// ...
private:
// ...
static double interestRate;
static double initRate();
};
double Account::interestRate = initRate(); // 定义并初始化一个静态成员
要想确保对象值定义一次, 最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中.
静态成员的类内初始化
通常, 类的静态成员不应该在类内部初始化. 然而, 可以为静态成员提供const整数类型的类内初始值, 不过要求静态成员必须是字面值常量类型的constexpr.
即使一个常量静态数据成员在类的内部被初始化了, 通常情况下也应该在类的外部定义一下该成员.
class Account {
public:
// ...
private:
// ...
static constexpr int period = 20; // period是常量表达式
};
// 一个不带初始值的静态成员的定义
int Account::period; // 初始值在类内提供
静态成员与非静态成员的其他区别:
- 静态数据成员可以是不完全类型. 特别地, 静态数据成员的类型可以就是它所属的类类型, 而非静态数据成员则受到限制, 只能声明它所属类的指针或引用.
class Bar
{
public:
//......
private:
static Bar mem1; //正确, 静态成员可以是不完全类型
Bar *mem2; //正确, 指针成员可以是不完全类型
Bar mem3; //错误, 数据成员必须是完全类型
};
- 静态数据成员可以作为默认实参, 非静态成员则不可以, 因为它的值本身属于对象的一部分, 这么做的结果是无法真正提供一个对象以便从中获取成员的值.
class Screen{
public:
Screen &clear(char = bkground)
private:
static const char bkground;
};