「生活可以更简单, 欢迎来到我的开源世界」
  1. 7.1 定义抽象数据类型
  2. 7.2 访问控制与封装
    1. 7.2.1 友元
  3. 7.3 类的其他特性
    1. 7.3.1 类成员再探
    2. 7.3.2 返回*this的成员函数
    3. 7.3.3 类类型
    4. 7.3.4 友元再探
  4. 7.4 类的作用域
    1. 7.4.1 名字查找与类的作用域
  5. 7.5 构造函数再探
    1. 7.5.1 构造函数初始化列表
    2. 7.5.2 委托构造函数
    3. 7.5.3 默认构造函数的作用
    4. 7.5.4 隐式的类类型转换
    5. 7.5.5 聚合类
    6. 7.5.6 字面值常量类
  6. 7.6 类的静态成员
C++ Primer 第7章 类
2018-06-20
」 「

数据抽象能帮助我们将对象的具体实现与对象所能执行的操作分离开来。

类的基本思想是数据抽象封装。数据抽象是一种依赖于接口实现分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、赋值接口实现的函数体以及定义类所需的各种私有函数。

封装实现了类的接口和实现的分离。类要想实现数据的抽象和封装,首先要定义一个抽象数据类型

7.1 定义抽象数据类型

一个抽象数据类型,我们可以通过它的接口使用它的对象,不可以直接访问它的数据成员。

定义在类内部的函数是隐式的inline函数。

成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。this形参是隐式定义的,任何定义名为this的参数或变量的行为都是非法的。this的目的总是指向“这个”对象,所以this是一个常量指针,不允许改变this中保存的地址。

total.isbn();
//total的地址传递给isbn的隐式形参this

默认情况下,this的类型是指向类类型非常量版本的常量指针,所以尽管this是隐式的,也要遵循初始化规则,意味着我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。

如果isbn是一个普通函数,this是一个普通的指针参数,则应该把this声明为const Sales_data *const。然而,this是隐式的并且不会出现在参数列表中,C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数

常量对象、常量对象的引用或者指针都只能调用常量成员函数。

一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当作左值返回,因此为了与它一致,combine函数必须返回引用类型:

Sales_data& Sales_data::combine(const Sales_data &rhs){
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}

类的作者常需要定义一些辅助函数,如add、read和print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但实际上并不属于类本身,这种函数的声明(而非定义)通常和类声明在同一个头文件中。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

每个类都分别定义了它的对象初始化的方式,控制类对象初始化的函数称为构造函数。构造函数的任务是初始化类对象的数据成员。构造函数的名字和类名相同,无返回类型。

构造函数不能被声明成const的。当我们创建一个类的const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

默认构造函数:无须任何实参,对对象执行默认初始化,无显示定义构造函数时,由编译器隐式定义。编译器创建的构造函数又称为合成的默认构造函数

合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,必须定义自己的默认构造函数,原因有三:

在C++11新标准中,如果我们需要其他形式的构造函数,也需要默认的构造函数,那么可以通过在参数列表后面写上= default来要求编译器生成构造函数。= default和声明一起出现在类内部时,默认构造函数是内联的;如果作为定义出现在类外部,则该成员默认情况下不是内联的。

可以使用构造函数初始值列表为鑫创建的对象的一个或多个数据成员赋初值,当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化:

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

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。如果我们不主动定义这些操作,则编译器将替我们合成它们。尽管编译器能替我们合成拷贝、赋值和销毁的操作,然而对于某些类来说合成的版本是无法正常工作的,特别是,当类需要分配类对象之外的资源外,合成的版本常常会失效。

7.2 访问控制与封装

C++语言中,使用访问说明符加强类的封装性:

一个类可以包含0个或多个访问说明符,而且对于某个访问说明符能出现的次数也没有严格规定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止。

封装有两个重要的优点:

struct和class的默认访问权限不一样,这是使用它们定义类的唯一区别。类可以在第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式:

7.2.1 友元

当数据成员是private时,外部函数即使是类的接口的一部分,但不是类的成员,则无法访问类的数据成员。

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元。如果把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可。友元声明只能出现在类定义的内部,但是出现位置没有限制,一般最好在类定义开始或结束的位置集中声明友元。友元不是类的成员也不受它所在区域访问控制级别的约束。

友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明

为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。

有些编译器允许在尚无友元函数的初始声明的情况下就调用它。但最好不要这样做,这样的话即使换了编译器也没必要改代码。

7.3 类的其他特性

7.3.1 类成员再探

用来定义类型的成员必须先定义后再使用,这一点与普通成员有所区别。因此,类型成员通常出现在类开始的地方。

定义在类内部的成员函数是自动inline的。我们可以在类内部把inline作为声明的一部分显示地声明成员函数,也可以在类的外部用inline关键字修饰函数的定义。虽然无须在声明和定义的地方同时说明inline,但是这么做也是合法的。最好只在类外部定义的地方说明inline,方便理解。与头文件中定义inline函数一样,inline成员函数也应该与相应的类定义在同一个文件中。

一个可变数据成员永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。

class Screen{
public:
void some_member() const;
private:
mutable size_t access_ctr;//即使在一个const对象内也能被修改
//该成员是一个可变成员,因此在任何成员函数,包括const函数在内都能改变它的值
};
void Screen::some_member() const{
++access_ctr;
}

类内数据成员初始值必须使用=的初始化形式或者花括号括起来的直接初始化形式。

7.3.2 返回*this的成员函数

inline Screen &Screen::set(char c);
inline Screen &Screen::move(int a, int b);

myScreen.move(4,0).set('#');
//等价写法:
myScreen.move(4,0);
myScreen.set('#')

返回引用的函数左值的,函数返回的是对象本身而非对象的副本。

inline Screen Screen::set(char c);
inline Screen Screen::move(int a, int b);


Screen temp = myScreen.move(4,0);
temp.set('#') //不会改变myScreen

如果不是返回引用,则move的返回值将是*this的副本。

一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。

通过区分成员函数是否const的,我们可以对其进行重载,根据隐式参数*this指针是否指向const而重载:

class Screen{
public:
//根据对象是否是const重载了display函数
Screen &display(std::ostream &os){
do_display(os);return *this;
}
const Screen &display(std::ostream &os) const{
do_display(os);return *this;
}
private:
void do_display(std::ostream &os) const{
od << contents;
}
}

当一个成员调用另一个成员时,this指针在其中隐式传递。当display的非常量版本调用do_display时,this指针将隐式地从指向非常量的指针转换成指向常量的指针。当do_display完成后,display函数各自返回解引用的this所得的对象,在非常量版本中,this指向一个非常量对象,因此返回一个非常量引用。

7.3.3 类类型

每个类定义了唯一的类型。对于两个类,即使成员完全一样,这两个类也是两个不同的类型。

类名可以作为类型名使用,或者跟在关键字class或struct后面:

Sales_data item1;
class Sales_data item1;//继承自C语言

类的声明和定义和函数一样,也可以分离开来。仅声明而暂时没定义的声明称作前向声明,引入名字并指明是一种类类型。类类型在声明之后定义之前是一个不完全类型:只知道是一个类类型,但不清楚包含哪些成员。

不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完成类型作为参数或者返回类型的函数。

创建类的对象之前类必须被定义过,类只有被定义了,才能用引用和指针访问其成员。

直到类被定义之后数据成员才能被声明成这种类类型,然而,一旦一个类的名字出现后,它就被认为声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:

class Link_screen{
Screen window;
Link_screen *prev;
Link_screen *next;
};

7.3.4 友元再探

友元:

重载函数尽管名字相同,但是它们仍然是不同的函数。如果一个类想把一组重载函数声明成它的友元,它需要对这组函数的每一个分别声明友元。

类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。

struct X{
friend void f(){
//友元函数可以定义在类内部
}
X() {f();} //错误,f还没有被声明
void g();
void h();
};
void X::g(){return f();}//错误,f还没有被声明
void f();
void X::h(){return f();}//正确,现在f的声明在作用域中

友元声明的作用是影响访问权限,本身并非普通意义上的声明

常见情况都需要添加前置声明,因为声明友元主要就是能够访问类的私有成员:

/*
声明友元类
*/
class B; //前置声明
class A{
private:
B* ptr; //A是B的友元类就,A可以访问B的私有成员
//使用B类作为对象,需要在A类的声明之前有B的声明
}
class B{
friend class A; //声明A为友元类,所以A的声明必须在B之前
}
/*
声明类的成员函数为友元
*/
class B;
class A{
public:
void F(B& b); //函数声明之前必须有B的声明
}
class B{
friend void A::F(B& b); //友元声明要求A::F(B& b)的声明在B类之前
}
void A::F(B& b){ //F函数定义之前必须有B的定义

}

7.4 类的作用域

每个类都会定义自己的作用域,一个类就算一个作用域的事实很好地解释了类外部定义成员函数时必须同时提供类名和函数名。

一旦遇到类名,定义的剩余部分就在类的作用域之内了,包括参数列表和函数体。我们可以直接使用类的其他成员而无需再次授权。

void Window_mgr::clear(ScreenIndex i){
//...
}

函数返回类型通常出现在函数名之前,因此当成员函数定义在类外部时,返回类型使用的名字位于类的作用域之外,这时,返回类型必须指明它是哪个类的成员:

Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s){
//...
}

7.4.1 名字查找与类的作用域

一般程序中名字查找的过程:

定义在类内部的函数来说,类的定义分两步处理:

编译器处理完类中的全部声明后才会处理成员函数的定义。

一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内存作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:

typedef double Money;
class Account{
public:
Money balance(){}
private:
typedef double Money;//错误:不能重新定义Money
Money bal;
}

成员定义中的普通块作用域的名字查找

最好不要把成员名字作为参数或其他局部变量使用。

尽管外层的对象被隐藏了,仍可以用作用域运算符访问它。

当成员定义在类外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域,还需要考虑在成员函数定义之前的全局作用域中的声明:

int height;
class Screen{
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0; //隐藏了外层作用域中的height
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos){
//var: 参数
//height: 类的成员
//verify: 全局函数
height = verify(var);
}

全局函数verify的声明在Screen类的定义之前是不可见的,然而,名字查找的第三步包括了成员函数出现之前的全局作用域,verify在setHeight的定义之前出现,因此可以正常使用。

7.5 构造函数再探

7.5.1 构造函数初始化列表

当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值:

string foo = "Hello World";//定义并初始化
string bar;//默认初始化成空string
bar = "Hello World";//

对象的数据成员的初始化和赋值也有类似区别。如果没有在构造函数的初始值列表中显示地初始化成员,则该成员将在构造函数体之前执行默认初始化。

有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类类型没有定义默认构造函数时,也必须初始化。

class ConstRef{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
ConstRef::ConstRef(int ii){
//赋值
i = ii; //正确
ci = ii; //错误:不能给const赋值
ri = i; //错误:ri没被初始化
}

//正确:显示地初始化引用和const成员
ConstRef::ConstRef(int ii):i(ii), ci(ii), ri(i){ }

随着构造函数体一开始执行,初始化就完成了,因此初始化const和引用类型的唯一机会就算通过构造函数初始值列表。

在很多类中,初始化和赋值的区别还事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。

构造函数初始值中每个成员只能出现一次。构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员的初始化顺序与它们在类定义中的出现顺序一致,构造函数初始值列表中初始值的前后位置并不会影响实际的初始化顺序

一般来说,初始化的顺序没有什么特别的要求,但是如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。

class X{
int i;
int j;
public:
//未定义错误:i在j之前被初始化
X(int val): j(val), i(j){}
};

在此例中,从构造函数初始值的形式上看仿佛是先用val初始化了j,再用j初始化i。实际上,i先被初始化,因此这个初始值的效果是试图使用未定义的值j初始化i。

建议:

如果一个类的构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数

7.5.2 委托构造函数

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){}
Salse_data(std::istream &is): Sales_data(){
read(is, *this);
}
}

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体依次执行。受委托的构造函数的函数体如果不为空,将先执行受委托的构造函数的函数体,然后控制权才会交还给委托者的函数体。

7.5.3 默认构造函数的作用

当对象被默认初始化或值初始化时,自动执行默认构造函数。

默认初始化在以下情况发生:

值初始化在以下情况发生:

类必须包含一个默认的构造函数以便在以上情况下使用,在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

Sales_data obj();	//正确:一个函数声明
Sales_data obj2; //正确:一个使用默认构造函数进行初始化的对象

7.5.4 隐式的类类型转换

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

class Sales_data{
public:
Sales_data(std::string s): bookNo(s), units_sold(0), revenue(0) {}
//...
}
string null_book = "9-999-99999-9";
//构造一个临时的Sales_data对象
//该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);

该调用是合法的,编译器用给定的string自动创建了一个Sales_data对象。

编译器只会自动地执行一步类型转换,如果代码隐式地使用了两种转换规则,则是错误的:

//错误:需要用户定义的两种转换
//(1)把“9-999-99999-9”转换成string
//(2)再把这个临时的string转换成Sales_data
item.combine("9-999-99999-9");

//正确:显示地转换成string,隐式地转换成Sales_data
item.combine(string("9-999-99999-9"));
//正确:隐式地转换成string,显示地转换成Sales_data
item.combine(Sales_data("9-999-99999-9"));

在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit阻止其实参进行隐式转换。关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于隐式转换,所以无须声明explicit。explicit声明只能在类内声明,类外部定义时不应重复

发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=),此时只能使用直接初始化而不能使用explicit构造函数:

Sales_data item(null_book);		//正确:直接初始化
Sales_data item2 = null_book; //错误:不能将explicit构造函数用于拷贝形式的初始化过程

尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显示地强制进行转换:

explicit Sales_data(const std::string &s): bookNo(s){}
explicit Sales_data(std::istream&){}

//正确:实参是一个显示构造的Sales_data对象
item.combine(Sales_data(null_book));
//正确:static_cast使用explicit的构造函数创建了一个临时的Sales_data对象
item.combine(static_cast<Sales_data>(cin));

7.5.5 聚合类

聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

//聚合类:Data
struct Data{
int ival;
string s;
};

//使用成员初始值列表初始化聚合类的数据成员
Data val = {0, "Anna"};
//错误:初始值的顺序必须与声明一致
Data val2 = {"Anna", 1024};

与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始化列表的元素个数绝对不能超过类的成员数量。

显示初始化类的对象的成员,存在三个明显的缺点:

7.5.6 字面值常量类

除了算术类型、引用和指针外,某些类也是字面值类型。字面值类型的类可能含有constexpr函数成员,这样的车关于必须符合constexpr函数的所有要求,它们是隐式const的。

数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。

constexpr构造函数可以声明成=default的形式或者=delete的形式。否则,它必须同时满足构造函数(不包含返回语句)和constexpr函数(能拥有的唯一可执行语句就是返回语句)的要求。综合这两点,constexpr构造函数的函数体一般是空的。

constexpr构造函数必须初始化所有数据成员,成员的初始值或者使用constexpr构造函数或者是一条常量表达式。

constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型。

7.6 类的静态成员

类的静态成员与类本身直接相关,而不是与类的各个对象保持关联。

通过在成员的声明之前加上关键字static使得其与类关联在一起。静态数据成员的类型可以是常量、引用、指针、类类型等。静态成员可以是public的或private的。

类的静态成员存在于任何对象之外,对象中不包含任何于静态数据成员有关的数据。

静态成员函数也不与任何对象绑定在一起,它们不包含this指针。

静态成员函数不能声明成const的,而且我们不能在static函数体内使用this指针。

使用类的静态成员:

double r = Account::rate();
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();

当在类外部定义成员函数时,不能重复static关键字,static关键字只能出现在类内部。

因为静态成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的,这意味着它们不是由类的构造函数初始化的,而且,一般来说, 不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员,和其他对象一样,一个静态成员只能定义一次。

我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员类型是字面值常量类型的constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所以适合于常量表达式的地方:

class Account{
public:
static double rate(){return intereastRate;}
static void rate(double);
private:
static constexpr int period = 30;
double daily_tbl[period];
};

//如果在类内部提供了一个初始值,则成员的定义不能再指定一个初始值了:
constexpr int Account::period; //初始值在类的定义内提供

如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的const或constexpr static不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。

即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员可以是不安全类型,特别的,静态成员的类型可以就是它所属的类类型,而非静态成员则受到限制,只能声明成它所属的类的指针或引用。静态成员和普通成员的另一个区别是我们可以使用静态成员作为默认实参。

<⇧>