:::info 类的基本思想是数据抽象(data abstraction)和 封装(encapsulation) :::
7.1 定义抽象数据类型
要想把Sales_data
变成抽象数据类型
,我们需耍定义一些操作以供类的用户使用。一旦Sales_data
定义了它自己的操作,我们就可以封装(隐藏)它的数据成员了。
成员函数和非成员函数的定义
:::info 成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,例如add、read、print等,它们的定义和声明都在类的外部。 :::
改进后的Sales_data
struct Sales_data{
//新成员:关于Sales_data对象的操作
std::string isbn()const {return bookNo;}
Sales_data& combine(const Sales_data&);
doubl eavg_price()const;
std::string bookNo;
unsigned units_sold=0;
double revenue=0.0;
}
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&,const Sales_data&);
std::ostream& print(std::ostream&,const Sales_data&);
std::istream& read(std::istream&,Sales_data&);
- 定义在类内部的函数是隐式的inline函数
- 尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外
this指针
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。
:::danger 因为this的目的总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this中保存的地址。 :::Sales_data total;
total.isbn();
//实际上可能是
Sales_data::isbn(&total);
this=&total;
std::string isbn() const{return this->bookNo;}
const成员函数
isbn
函数的另一个关键之处是紧随参数列表之后的const
关键字,这里const
的作用是修改隐式this指针的类型。 :::info 默认情况下, this的类型是指向类类型非常量版本的常量指针。例如在Sales_data
成员函数中,this
的类型是Sales_data* const
。尽管this
是隐式的,它仍然要遵循初始化规则,意味着(在默认情况下)我们不能把this
绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数 :::
若想在常量对象上调用普通成员函数,有两种方式:
- 把this的类型变成
const Sales_data* const
,但是不可行,因为this是隐式的 使用常量成员函数,表示this指向一个常量
string isbn() const{ }
类作用域
:::info 类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内 :::
编译顺序
编译器分两步处理类:
首先编译成员的声明,然后才轮到成员函数体(如果有的话)。
因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
类外定义成员函数
像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。
double Sales_data::avg_price()const{
if(units_sold)
return revenue/units_sold;
else
return 0;}
函数名
Sales_data::avg_price
使用作用域运算符来说明如下的事实: 我们定义了一个名为avg_price
的函数,并且该函数被声明在类Sales_data
的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。定义类相关的非成员函数
我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来
一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内
构造函数
:::info 每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过祝,这些函数叫做构造函数(constructor)。构造函数的仟务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。 :::
构造函数的名字和类名相冋。和其他函数不一样的是,构造函数没有返回类型;
-
合成的默认构造函数
:::info 类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(defaultconstructor)。默认构造函数无须任何实参 :::
合成的默认构造函数
:::tips 如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数,这个默认构造函数被称为合成的默认构造函数(synthesized default constructor) ::: 合成的默认构造函数只适合非常简单的类,比如现在定义的这个Sales_data版本。对于一个普通的类来说,必须定义它自己的默认构造函数
构造规则如下: 若是有类内初始值则用类内初始值
-
有时无法使用合成默认构造函数
:::danger
编译器只会在没有任何构造函数的时候才会生成合成默认构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。
- 第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作,如涉及指针等操作的时候
第三个原因是有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。 :::
=default
在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上
=default
来要求编译器生成构造函数。struct Sales_date{
Sales_data()=default;
//……
}
构造函数初始值列表
Sales_data(const std::string& s):bookNo(s){}
Sales_data(const std::string& s,unsigned n,double p):
bookNo(s),units_sold(n),(p*n){}
冒号和花括号之间的部分是构造函数初始值列表(
constructor initializelist
),它负责为新创建的对象的个或几个数据成员赋初值。 :::info 构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。 ::: 尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特別是,当类需要分配类对象之外的资源时,合成的版本常常会失效。7.2 访问控制与封装
访问说明符
在C++语言中,我们使用访问说明符(access specifiers)加强类的封装性: :::info
定义在
public
说明符之后的成员在整个程序内可被访问,public
成员定义类的接口。定义在
private
说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private
部分封装了(即隐藏了)类的实现细节。 :::class和struct的区别
:::info 唯一的一点区别是,struct和class的默认访问权限不太一样。 :::
如果我们使用
struct
关键字,则定义在第一个访问说明符之前的成员是public
的;- 相反,如果我们使用
class
关键字,则这些成员是private
的。友元
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend
)。如果类想把一个函数作为它的友元,只需要增加一条以friend
关键字开始的函数声明语句即可 ```cpp class Sales_data{ //为Sales_iata的非成员函数所做的友元声明 friend Sales_data add(const Sales_data&, const Sales_data&); friend std::istream& read(std::istream&,Sales_data&); friend std::ostream& print(std::ostream&, Sales_data&);
public: Sales_data()=default; Sales_data(const std::string& s,unsigned n, double p):bookNo(s),units_sold(n),revenue(pn){} /…*/ private: std:: string bookNo; unsigned units_sold=0; double revenue=0.0; }; //Sales_data接口的非成员组成部分的声明 Sales_data add(const Sales_data&,const Sales_data&); std::istream& read(std::istream&,Sales_data&); std::ostream& print(std::ostream&, Sales_data&);
最好在类定义开始或结束前的位置集中声明友元
<a name="pi1CN"></a>
### 友元的声明
:::tips
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,**那么我们就必须在友元声明之外再专门对函数进行一次声明。**
:::
些编译器允许在尚无友元函数的初始声明的情况下就调用它。不过即使你的编译器支持这种行为,最好还是提供一个独立的函数声明。
封装有两个重要的优点
- 确保用户代码不会无意间破坏封装对象的状态。
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
<a name="ZH5v8"></a>
# 7.3 类的其他特性
<a name="cCB4T"></a>
## 类成员再探
<a name="iUfOD"></a>
### 定义类型的别名
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是`public`或者`private`中的一种
```cpp
class Screen{
public:
typedef std::string::size_type pos;
private:
pos cursor=0;
pos height=0,width=0;
std::string contents;
}
令成员函数作为内联函数
我们可以在类的内部把inline
作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline
关键字修饰函数的定义
inline Screen& Screen::move(pos r,pos c){}
重载成员函数
和非成员函数一样,成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行。
mutable
有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。
class Screen{
public:
void some_member()const;
private:
//即使在一个const对象内也能被修改mutable
mutable size_t access_ctr;
};
void Screen::some_member()const
{
++access_ctr;
//保存一个计数值,用于记录成员函数被调用的次数++access_ctr
}
一个可变数据成员(mutable data member)永远不会是const
,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。
类内初始值:
:::info 如我们之前所知的,类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化所用的)。 :::
基于const的重载
class Screen{
public:
//...
Screen& display(&ostream os)const;
};
int main(){
Screen myScreen;
myScreen.display(cout).set('*');//wrong!
}
一个const成员函数如果以引用的形式返回*this
,那么它的返回类型将是常量引用。若是想要继续修改则不可修改。
要是想要修改怎么办?
:::tips
通过区分成员函数是否是const
的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向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{os<<contents;}
};
当一个成员调用另外一个成员时,
this
指针在其中隐式地传递。因此,当display
调用do_display
时,它的this
指针隐式地传递给do_display
。而当display
的非常量版调用do_display
时,它的this
指针将隐式地从指向非常量的指针转换成指向常量的指针类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型
前向声明
就像可以把函数的声明和定义分离开来一样(参见6.1.2节,第186页),我们也能仅仅声明类而暂时不定义它
class Screen;
前向声明(forward declaration)向程序中引入了名字
Screen
并且指明Screen
是一种类类型。对于类型Screen
来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知**Screen**
是一个类类型,但是不清楚它到底包含哪些成员。不完全类型的使用场景
:::info 不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。 :::
指针、引用
- 声明作为参数或者返回值
友元再探
:::info 类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的 :::
友元类
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
class Screen{
friend class Window_mgr;
//
};
友元不具备传递性
必须要注意的一点是,友元关系不存在传递性。也就是说,如果Window_mgr
有它自己的友元,则这些友元并不能理所当然地具有访问Screen
的特权。
成员函数成为友元
当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类
class Window_mgr{
public:
void Window_mgr();
};
class Screen{
friend void Window_mgr::clear();
};
设计顺序
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。 :::tips
- 首先定义
Window_mgr
,声明clear
但是不定义 - 再定义
Screen
,并且把clear
声明成友元 最后定义
clear
,这样clear
才能使用Screen
的成员 ::: Why?和类的编译顺序有关 :::info 如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明 :::7.4 类的作用域
定义在类外的成员
一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名
- 另一方面,函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。 ```cpp class Window_mgr{ public: //向窗口添加一个Screen,返回它的编号 ScreenIndex addScreen(const Screen&); };
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen& s){
};
<a name="maHXs"></a>
## 类成员的名字查找
对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现得不太明显。
:::info
类的定义分两步处理:
1. 首先,编译成员的声明。
1. 直到类全部可见后才编译函数体
:::
- 编译器处理完类中的全部声明后才会处理成员函数的定义。
- 因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。
<a name="i3V6u"></a>
### 用于类成员声明的名字查找
:::tips
这种两阶段的处理方式**只适用于成员函数中**使用的名字。**声明**中使用的名字,包括**返回类型或者参数列表**中使用的名字,**都必须在使用前确保可见**。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。
:::
```cpp
typedef double Money;
string bal;
class Account{
public:
Money balance(){
return bal;
}
private:
Money bal;
};
- 对于
balance
的声明,Money
需要是可见的,于是先在类内寻找定义,然后再类外找到typedef
定义的Money - 另一方面,
balance
函数体在整个类可见后才被处理,因此,该函数的return
语句返回名为bal
的成员,而非外层作用域的string
对象。
类型名要特殊处理
然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字
typedef double Money;
string bal;
class Account{
public:
Money balance(){
return bal;
}
typedef double Money;//wrong!
private:
Money bal;
};
成员函数定义中的普通块作用域中名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
1、现在函数定义中查找
2、再在类作用域中查找
3、为了强行使用外层的,需要用域限定符
:::info
这里和前面的区别是又特质成员函数中的某块作用域.
:::
外围作用域中查找
如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求
void Screen::dummyfnc(){
cursor=width*::height//此处height即为全局变量而非类中的变量
}
尽管外层的对象被隐藏掉了,但我们仍然可以用作用域运算符访问它。
7.5 构造函数再探
构造函数初始值列表
:::info 就对象的数据成员而言,初始化和赋值也有类似的区別。如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。 :::
构造函数的初始值有时候必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。
- 如果成员是const或者是引用的话,必须将其初始化。
类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
成员初始化顺序
:::info 成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。 :::
默认实参
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
委托构造函数
C++新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
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):Salesdata()
{read(is,*this);}
};
默认构造函数的使用
对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一个用默认构造函数初始化的对象:
Sales_data obj1();//wrong
Sales_data obj2;
如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对
隐式的类类型转换
:::info 我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor) ::: 编译器只会自动地执行一步类型转换
item.combine("99-999-999");
//wrong
这里想要执行的是
字面值->string
string->Sales_data
实际上执行了两步转换,因此会报错
item.combine(string("99-999-999"));//正确1:显式转换string,隐式转换Sales_data;
item.combine(Sales_data("99-999-999"));
explicit抑制构造函数的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为
explicit
加以阻止class Sales_data{
public:
Sales_data()=default;
Sales_data(const std::string&s,unsigned n,double p):
bookNo(s),units—sold(n),revenue(p*n){}
explicit Sales_data(const std::string&s):bookNo(s){}
explicit Sales_data(std::istream& is);
}
:::info 此时,没有任何构造函数能用丁隐式地创建
Sales_data
对象,之前的两种用法都无法通过编译 :::只能用于直接初始化
:::tips
explicit
构造函数只能用于直接初始化。发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit
构造函数 :::string notebook="99-999-9999";
Sales_data item1(notebook);//right 直接初始化
Sales_data item2=notebook;//wrong
:::danger 当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用; 而且,编译器将不会在自动转换过程中使用该构造函数 :::
聚合类
聚合类(
aggregate class
)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的 :::info所有成员都是
public
的。- 没有定义任何构造函数。
- 没有类内初始值。
没有基类,也没有virtual函数, ::: 例子
struct aggclass{
int val;
string s;
}
//我们可以提供一个花括号拈起来的成员初始值列表,并用它初始化聚合类的数据成员:
aggclass ag={1,"Diana"};
字面值常量类
我们提到过
constexpr
函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不问,字面值类型的类可能含有constexpr函数成员
。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的数据成员都是字面值类型的聚合类是字面值常量类
或者
- 数据成员都必须是字面值类型。
- 类必须至少含有一个constexpr构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式,或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
-
constexpr构造函数
尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数
- constexpr构造函数体一般来说应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了
7.6 类的静态成员
:::info 有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。 :::声明静态成员
我们通过在成员的声明之前加上关键字static
使得其与类关联在一起。和其他成员—样,静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等
类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。
-
使用静态成员
我们使用作用域运算符直接访问静态成员:
double r;
r=Account::rate();
虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员
定义静态成员
:::tips 和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句 :::
初始化
因为静态数椐成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。