C++ Primer 第七章 类
第七章 类
7.1 定义抽象数据类型
类的基本思想
- 数据抽象
- 依赖于接口和实现分离的编程(以及设计)技术
- 封装
- 实现了类的接口和实现的分离
7.1.1 成员函数
- 成员函数的声明必须在类的内部
- 成员函数的定义既可以在类的内部也可以在类的外部
- 定义在类内部的函数是隐式的inline函数
- 成员函数通过名为this的隐式参数访问调用它的对象,当我们调用一个成员函数时,用请求该函数的对象地址初始化this
- e.g.:如果调用
total.isbn()
,编译器会将调用重写为isbn(&total)
- e.g.:如果调用
- 在成员函数内部,任何对类成员的直接访问都被看做this的隐式掉用,也就是当我们使用成员变量BookNo的时候,实际上隐式的使用了this指向的成员,就像
this->BookNo
一样
7.1.2 const 成员函数
std::string isbn() const {return BookNo;}
- const的作用是修改隐式this指针类型
- this指针在默认情况下的类型是指向类类型非常量版本的const指针
- 意味着默认情况下我们不能把this绑定到一个常量对象上(否则this就会认为他指向的对象是能够被修改的)
- 因此,我们不能在常量对象上调用普通的成员函数
- 因此,如果成员函数不会改变this所指的对象,我们应该把this声明为指向常量版本的const指针,有助于提高函数的灵活性
- 因为我们没有地方修改隐式形参this的属性,所以C++的做法是允许把const放在成员函数的参数列表之后,表示this是一个指向常量的指针,这种函数被称为常量成员函数
- 常量对象以及常量对象的引用或指针都只能调用常量成员函数
7.1.3 类作用域
- 编译器分两步处理类
- 首先编译成员的声明
- 然后轮到成员函数体,因此成员函数体可以随意使用类中的其他成员而无需在意这些成员出现的次序
- 在类的外部定义成员函数时,成员函数的定义必须与他的声明匹配,返回类型,参数列表必须和声明保持一致,如果成员被声明为常量成员函数,他的定义也必须在参数列表后明确指定为const属性
7.1.4 构造函数
构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数
- 构造函数的名字和类名相同
- 构造函数没有返回类型
- 类可以包含多个构造函数,和重载函数差不多
- 构造函数不能被声明为const,当我们创建类的const对象时,直到构造函数完成初始化的过程,对象才能真正取得其“常量”属性,因此构造函数在const对象的构造过程中可以向其写值
7.1.4.1 默认构造函数
如果类没有定义任何构造函数,对象会执行默认初始化,类通过默认构造函数来控制默认初始化的过程
- 如果我们的类没有显式定义构造函数,编译器就会为我们隐式定义一个默认构造函数
- 编译器创建的构造函数又被称为合成的默认构造函数,按照以下规则初始化数据成员
- 如果存在类内的初始值,用它初始化成员
- 否则默认初始化该成员
某些类不能依赖于合成的默认构造函数,合成的默认构造函数只适合非常简单的类,对于普通的类,必须定义它自己的默认构造函数,原因有三
- 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数,一旦我们定义了其他的构造函数,除非我们再定义一个默认构造函数,否则类将没有默认构造函数
class A {
public:
A(int a){}
};
int main() {
A x; //error: 类'A'不存在默认构造函数
return 0;
}
-
对于某些类,合成的默认构造函数可能执行错误的操作,例如数组和指针被默认初始化,他们的值将是未定义的
- 当类包含有内置类型或者复合类型的成员,只有当这些成员全部被赋予了类内初始值时,类才适用于合成的默认构造函数
-
有的时候编译器不能为某些类合成默认的构造函数,例如类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数
= default
形如A() = default
的构造函数
- 首先它不接受任何实参,是一个默认构造函数
- 在参数列表后面写上 = default 表示要求编译器生成构造函数,这个函数的作用完全等同于之前使用的合成默认构造函数
- = default可以和声明一起出现在类的内部,也可以作为定义出现在类的外部
7.1.4.2 拷贝,赋值与析构
- 如果我们不主动定义这些操作,编译器将替我们合成他们,一般来说,编译器生成的版本对对象的每个成员进行拷贝、赋值和销毁的操作
- 对于某些类来说合成的版本无法正常工作,特别是类需要分配类对象以外的资源时,合成的版本常常失效
- 需要使用动态内存的类应该使用vector对象或者string对象管理必要的存储空间,这样可以避免分配和释放内存带来的复杂性
7.2 访问控制与封装
7.2.1 访问说明符
- public:成员在整个程序内可被访问
- private:成员可以被类的成员函数访问,但是不能被使用该类的代码访问
- private部分封装了类的实现细节
- struct和class的区别仅仅是形式上有所不同,我们可以用两个关键字中的任何一个定义类,唯一的区别是默认访问权限不一样
- struct关键字定义在第一个说明符之前的成员是public的
- class关键字定义在第一个说明符之前的成员时private的
7.2.2 友元
类可以允许其他类或者其他函数访问它的非公有成员,方法是令其他类或者函数成为他的友元(friend)
- 友元只能出现在类定义的顶部,但是在类内出现的具体位置不限
- 友元不是类的成员也不受他所在区域访问控制级别的约束
- 友元的声明仅仅指定了访问的权限,而非通常意义上的函数声明,如果我们希望类的用户能够调用某个友元函数,必须在友元声明之外再专门对函数进行一次声明
7.3 类的其他特性
7.3.1 定义类型成员
如下,在Screen的public部分定义了pos,这样用户就可以使用pos,Screen的用户不应该知道Screen使用了一个string对象存放它的数据,因此通过把pos定义为public可以隐藏Screen的细节
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;
};
7.3.2 可变数据成员
有时我们希望能修改类的某个数据成员,即使是在一个const成员函数内,可以通过在变量的声明中加入mutable关键字来做到这一点。
- 一个可变数据成员永远不会是const,即使它是const对象的成员
class A {
public:
void some_member() const;
private:
mutable size_t cnt;
};
void A::some_member() const {
++cnt; //保存计数值,用于记录成员函数被调用的次数
}
7.3.3 类数据成员的初始值
有时我们希望某个成员开始时总是拥有一个默认初始化的值,C++11中最好的方式是把这个默认值声明成一个类内初始值
class Window_mgr {
private:
//默认情况下,一个Windows_mgr 包含一个标准尺寸的空白Screen
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
- 类内初始值必须使用=或者花括号表示
7.3.4 返回*this的成员函数
class Screen {
public:
Screen& move(pos r, pos c) {
//move..
return *this;
}
Screen& set(char c) {
//set..
return *this;
}
};
//main
Screen myScreen;
myScreen.mov(4,0).set('#'); //把光标移动到指定位置然后设置该位置的字符值
- 如上,我们可以利用
return *this
来获得我们当前的这个对象,返回引用的函数时左值的,意味着返回的是对象而不是对象的副本 - 如果我们把Screen&变成Screen的话,事情就会变成
Screen tmp = myScreen.move(4,0); tmp.set('#');
,这样set只能改变临时副本tmp的值,不能改变myScreen的值了
如果从const成员函数返回this,那么此时this应该是一个const指针,this是一个const对象,这种时候我们就不能把this指针嵌入到动作的序列中了
myScreen.displyay(cout).set('*'); //const成员函数display返回了常量引用,set会发生错误
7.3.5 基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载
- 原理和根据指针参数是否指向const而重载函数的原因相似
- 因为非常量版本的函数对于常量对象不可用,而对于非常量对象而言,非常量版本的函数往往是更好的匹配
- 相当于重载了底层const的this指针
struct A {
public:
int& fun() {
//do something
}
const int &fun() const {
//do something
}
};
7.3.6 类类型
- 每个类定义了唯一的类型,对于两个类来说,即使他们的成员完全一样,他们也是不同的类型。
- 可以把类名作为类型的名字使用,从而指向类类型
- 我们可以仅仅声明类而暂时不定义他,例如
class A;
- 这种声明有时被称为前向声明,他向程序引入名字A并且指明它是一种类类型
- 在它声明之后定义之前是一个不完全类型,也就是我们知道A是一个类类型,但是不知道他到底包含哪些成员
- 不完全类型可以定义指向这种类型的指针或者引用,也可以声明(不能定义)以他作为参数或者返回类型的函数
7.3.7 友元的其他性质
类之间的友元关系
class A {
friend class B; //B类的成员可以访问A类的私有部分
};
- 每个类负责控制自己的友元类或者友元函数
成员函数作为友元
class A {
friend void B::fun(int i); //B的成员函数fun可以访问A的私有部分
};
- 如果想要把一组重载函数声明为他的友元,需要挨个声明每个函数
友元的声明和作用域
class A {
friend void f() {
//友元函数的定义可以在类内部
}
A() {
f(); //error:f还未声明
}
void g();
void h();
};
void A::g() {
return f(); //error:f还未声明
}
void f(); //声明f
void A::h() {
return f(); //true:f已经声明在作用域中
}
//tips:编译器有时候并不强制执行上面的友元限定规则
7.4 类的作用域
7.4.1 名字查找
名字查找:寻找与所用名字最匹配类的声明
- 首先在名字所在的块中寻找声明语句,只考虑在名字使用之前出现的
- 如果没找到,继续查找外层作用域
- 如果最终没有找到,程序报错
对于类内部成员函数来说,名字查找与上述规则有所区别,类的定义分两步处理
- 首先编译成员的声明
- 直到类全部可见之后编译函数体
编译器处理完类中的全部声明后才会处理成员函数的定义
typedef double Money;
class A {
public:
Money fun(); //使用外层作用域的Money
typedef double Money; //error:不能重新定义Money
};
7.5 构造函数进阶
7.5.1 构造函数初始值列表
- 构造函数初始值有时候必不可少
- 我们有时候可以忽略数据成员初始化和赋值之间的差异,但是如果成员是const或者引用的话,必须将其初始化
class A {
public:
A(int t) { //赋值的形式,不合适
a = t; //true
b = t; //false
c = a; //false
}
A(int t):a(t),b(t),c(a){} //正确:显式的初始化引用和const成员
private:
int a;
const int b;
int &c;
};
- 除此之外,初始化和赋值的区别事关底层效率的问题
- 前者直接初始化数据成员
- 后者先初始化再赋值
7.5.2 成员初始化的顺序
- 构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序
- 成员的初始化顺序与他们在类定义中出现的顺序一致:第一个成员先被初始化,然后第二个。。。
- 如果用一个成员来初始化另一个成员,那么初始化的顺序就很关键了
class A {
int i;
int j;
public:
A(int val): j(val), i (j) {}
//看上去是先用val初始化j,然后用j初始化i
//然而实际上是先用j初始化i,然后用val初始化j,很显然第一个行为是未定义的
}
因此,最好令构造函数初始值的顺序与成员声明的顺序保持一致,并且尽量避免用某些成员初始化其他成员
7.5.3 默认实参和构造函数
如果一个构造函数为所有参数提供了默认实参,则它实际上也定义了默认构造函数
class A {
public:
A(int a = 0); //他也是默认构造函数
};
7.5.3 委托构造函数
一个委托构造函数使用它所属类的其他构造函数执行她自己的初始化过程,或者说把它自己的一些(或者全部)职责委托给了其他构造函数
class Sales_data {
public:
//非委托构造函数使用对应实参初始化成员
Sales_data(std::string s, unsigned int 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);
}
};
7.5.4 隐式的类类型转换
- 如果构造函数只接受一个实参,那么他实际上定义了转换为此类类型的隐式转换机制,这种构造函数也被称作转换构造函数
- 编译器只会自动地执行一步类型转换
//路径"999" -> string -> 类A
fun("99999"); //错误
fun(string("999")); //true:显式的转换为string,然后隐式转换为A
fun(A("999")); //true:隐式转换为string,然后显式地转换为A
- 我们可以通过将构造函数声明为explicit 阻止隐式类类型转换
class A {
public:
explicit A(const std::string &s);
};
func(string("123")); //error
A a = string("123"); //error: explicit不能用于拷贝形式的初始化过程
fun(static_cast<A>(string("123"))); //true:static_cast可以使用explicit的构造函数
标准库中的explicit(e.g.)
- 接收单参数的const char*的string构造函数不是explicit
- 接收容量参数的vector构造函数时explicit
7.5.5 聚合类
聚合类使得用户可以直接访问它的成员,并且具有特殊的初始化语法形式,当类满足以下条件的时候,它是聚合的
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类和virtual函数
class Data {
int val;
string s;
};
Data vall = {0, "Anna"}; //我们可以用花括号括起来的成员初始值列表初始化聚合类的数据成员
//初始值的顺序必须和声明的顺序一致
Data val2 = {"Anna", 2}; //error:不能用"Anna"初始化val,2初始化s
//初始值列表的元素个数如果少于类的成员的数量,靠后的成员被值初始化
聚合类存在三个明显的缺点
- 要求所有成员是public
- 把初始化成员的任务交给了用户,容易出错
- 添加或者删除成员后所有的初始化语句都要更新
7.5.6 字面值常量类
字面值常量类要求数据成员都是字面值类型的聚合类,或者满足以下要求
- 数据成员都是字面值类型
- 类至少包含一个constexpr构造函数
- 数据成员如果有类内初始值,必须是一条常量表达式。数据成员如果是类类型,必须使用自己的constexpr构造函数
- 必须使用析构函数的默认定义
constexpr可以声明为=default的形式,否则它必须满足两个要求
- 符合构造函数的要求(意味着不能包含返回语句)
- 符合constexpr函数的要求(意味着能拥有的唯一可执行语句是返回语句)
- 综合以上来看,constexpr构造函数体一般是空的
constexpr构造函数必须初始化所有数据成员(初始值或使用constexpr构造函数或是常量表达式)
7.6 类的静态成员
- 有时候类需要他的一些成员和类本身直接相关,而不是和类的各个对象保持关联
- 类的静态成员存在于任何对象之外,不包含任何与静态数据成员有关的数据
- 静态成员函数也不与任何对象绑定,不包含this指针,自然不能被声明为const的
class A{
public:
void func();
static double sdfunc();
static void svfunc(double);
private:
std::string s;
double d;
static double sd;
static double sdfunc2();
};
//调用static成员函数
double r = Account::sdfunc();
//我们仍然可以通过对象调用static成员
A a;
A* pa = &a;
r = a.sdfunc(); //并且不需要通过作用域运算符了
r = pa->sdfunc();
//定义static函数,static只出现在类内部,内外部就不能加上这个关键字了
void svfunc(double a) {
//...
}
//静态成员不属于任何一个对象,所以他们并不是在对象创建的时候被定义的,因此我们必须在类外定义和初始化每个静态成员
//静态数据成员定义在任何函数之外,一旦被定义就存在于程序的整个生命周期
double Account::sd = sdfunc2();
通常情况下,类的static成员不应该在类内初始化,但是我们可以为他提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
- e.g.
static constexpr int p = 30;
- 即使常量静态数据成员在类内部被初始化了,通常情况下也应该在类外部定义一下该成员
静态成员可以用于某些场景,普通成员不能
class A {
//静态成员可以作为默认实参
void func(int c = x); //x表示在类中稍后定义的静态成员
void func(int c = y); //error:非静态数据成员不能作为默认实参
private:
static int x;
int y;
static A a; //正确:静态成员可以是不完全类型
A* b; //正确:指针可以是不完全类型
A c; //错误:数据成员必须是完全类型
};