第七章 类
定义在类内部的函数是隐式的
inline
函数,外部不是
类内函数体前加 const
该方法不能更改成员变量
书本解释:紧随参数列表之后的
const
关键字的作用是修改隐式this
指针的类型
默认情况下,this
的类型是指向类类型非常量版本的常量指针。例如在某类比如 Sales_data
成员函数中,this
的类型是 Sales_data* const
。尽管 this
是隐式的,但它仍然需要遵循初始化规则,意味着( 在默认情况下 )我们不能把 this
绑定到一个常量对象上。这一情况使得我们不能在一个常量对象上调用普通的成员函数。
把 this
声明称 const Sales_data* const
有助于提供函数的灵活性。
然而,this
是隐式的并且不会出现在参数列表中,所以在哪儿将 this
声明成指向常量的常量指针呢?C++
的做法是允许把 const
关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的 const
表示 this
是一个指向常量的指针。像这样使用 const
的成员函数被称作 常量成员函数( const member function )
// 伪代码
std::string Sales_data::isbn() const { return bookNo; }
// -->
std::string Sales_data::isbn(const Sales_data* const this){
return this->bookNo;
}
类作用域和成员函数
编译器分两部处理类:首先编译成员的声明,然后才轮到成员函数体( 如果有的话 )。因此,成员函数体可以随意使用类中的其他成员而无须在意出现次序
在类的外部定义成员函数
当在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说返回类型、参数列表和函数名都得于类内部的声明保持一致。如果成员被声明称常量成员函数,那么它的定义也必须在参数列表后明确指定 const
属性。同时,类外部定义的成员u你的名字必须包含它所属的类名
struct Salse_data{
...
double avg_price() const;
...
}
double Sales_data::avg_price() const{
if( units_sold )
return revenue / units_sold;
else return 0;
}
定义 read 和 print 函数
一般来说,如果非成员函数是类结果的组成部分,则这些函数的声明应该与类在同一个文件中
// 输入的交易信息包括 ISBN、售出总数和售出价格
istream& read(istream& is, Sales_data& item){
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream& print(ostream& os, const Sales_data& item){
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
因为 IO 类属于不能被拷贝的类型,因此我们只能通过引用来传递他们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接收的都是普通引用,而非对常量的引用。
定义 add 函数
Sales_data add(const Sales_data& lhs, const Sales_data &rhs){
Sales_data sum = lhs;
sum.combine(rhs); // 把 lhs 的数据成员加到 sum 当中
return sum;
}
构造函数
构造函数不能被声明成
const
的。当我们创建类的一个const
对象时,直到构造函数完成初始化过程,对象才能真正取得其 “常量” 属性。所以,构造函数在const
对象的构造过程中可以向其写值。
关键概念:封装的益处
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码
一旦把数据成员定义成
private
的,类的作者就可以比较自由地修改数据了。当实现部分改变时,我们只需要检查类的代码本身以确认这次改变有什么影响;换句话说,只要类的接口不变,用户代码就无须改变。如果数据时public
的,则所有使用了原来数据成员的代码都可能失效,这时我们必须定位并重写所有依赖于老版本实现的代码,之后才能重新使用该程序
把数据的成员的访问权限设成
private
还有另外一个好处,这么做能防止由于游湖的原因造成数据被破坏。如果我们发现有程序缺陷破坏了对象的状态,则可以在有限的范围内定位缺陷:因为只有实现部分的代码可能产生这样的错误。因此,将查错限制在有限范围内将能极大地降低维护代码及修正程序错误的难度
尽管当类的定义发生改变时无须更改用户代码,但是使用了改了ide源文件必须重新编译
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么必须在友元声明之外再对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,对于本书的 Sales_data
头文件应该为 read、print 和 add
提供独立的声明( 除了类内部的友元声明之外 )
类成员再探
定义一个Screen类
class Screen{
public:
typedef std::string::size_type pos;
Screen() = default; // 因为 Screen 有另一个构造函数
// 所以本函数必需
// cursor 被其类内初始化为 0
Screen(pos ht, pos wd, char c):height(ht), width(wd),contents(ht * wd, c){}
char get() const{
return contents[cursor]; // 读取光标处的字符 隐式内联
}
inline char get(pos ht, pos wd) const; // 显示内联
Screen& move(pos r, pos c); // 能在之后被设为内联
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
}
inline // 可以在函数定义出指定 inline
Screen& Screen::move(pos r, pos c){
pos row = r * width; // 计算行的位置
cursor = row + c; // 在行内将光标移动到指定的列
return* this; // 以左值的形式返回对象
}
char Screen::get(pos r, pos c) const // 在类内部声明称 inline
{
pos pow = r * width; // 计算行的位置
return contents[row + c] // 返回给定列的字符
}
可变数据成员
这种情况不频繁,但是会发生。希望能修改类的某个数据成员,即使是在一个 const
成员函数内。可以通过 mutable
关键字做到
一个可变数据成员( mutable data member ) 永远不会是 const
, 即使它是 const
对象的成员。
class Screen{
public:
void some_member() const;
private:
mutable size_t access_ctr; // 即使在一个 const 对象内也能被修改
};
void Screen::some_member() const{
++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数
// 该成员需要完成的其他工作
}
**从 const 成员函数返回 *this **
假如实现一个 display
函数,并且是一个 const
成员,那么 this
将是一个指向 const
的指针,而 *this
是 const
对象。由此,display
的返回类型应该是 const Sales_data&
。然而,如果真的令 display
返回一个 const
的引用,则不能将 display
嵌入到一组动作的序列中去:
Screen myScreen;
// 如果 display 返回常量引用,调用对数据有更改的函数将引发错误
myScreen.display(cout).set('*');
即使 myScreen
是个非常量对象,对 set
的调用也无法通过编译。问题在于 display
的 const
版本返回的是常量引用。
在下面的例子中,定义一个 do_display
的私有成员,由它负责打印 Screen
的实际工作。所有的 display
操作都将调用这个函数,然后返回执行操作的对象。
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:
// 该函数负责显示 Screen 的内容
void do_display(std::ostream& os) const { os << contents; }
// 其他成员和之前一致
};
当 display
调用 do_display
时,它的 this
指针隐式地传递给 do_display
。而当 display
的非常量版本调用 do_display
时,它的 this
指针将隐式地从指向非常量的指针转换成指向常量的指针 (void do_display(std::ostream& os) const { os << this.contents };
const
后面发生了转换应该是)
当 do_display
完成后,display
函数各自返回解引用 this
所得的对象。在某个对象上调用 display
时,该对象是否时 const
决定了应该调用 display
哪个版本:
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // 调用非常量版本
blank.display(cout); // 调用常量版本
类的声明
class Screen; // Screen 类的声明
这种声明有时被称作前向声明( forward declaration ), 它向程序中引入了名字 Screen
并且指明 Screen
是一种类类型。对于类型 Screen
来说,在它声明之后,定义之前是一个不完全类型( incomplete type )。
不完全类型只能在非常有限的场景下使用:可以定义指向这种类型的指针或引用,也可以声明( 但是不能定义 )以不完全类型作为参数或者返回类型的函数
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明,否则,编译器无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能引用或者指针访问其成员。
另外一种情况:直到类被定义之后数据成员才能被声明称这种类类型。也就是说,我们必须首先完成类的定义,然后编译器才能直到存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然后,一旦一个类的名字出现后,它就被认为是声明过了( 但尚未定义 ),因此类允许包含含有它自身类型的引用或指针
class Link_screen{
Screen window;
Link_screen* next;
Link_screen* prev;
};
友元再探
类可以把普通的非成员函数定义成友元。还可以把其他的类定义成友元,也可以把其他类的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
假如,一个 Window_mgr
类的某些成员可能需要访问它管理的 Screen
类的内部数据。例如,假设需要为 Window_mgr
添加一个名为 clear
的成员,它负责把一个指定的 Screen
的内容都设为空白。为了完成这一任务,clear
需要访问 Screen
的私有成员;而要想令这种访问合法,Screen
需要把 Window_mgr
指定成它的友元:
class Screen{
// Window_mgr 的成员可以访问到 Screen 类的私有部分
friend class Window_mgr;
// Screen 的剩余部分
};
由此可以将 Window_mgr
的 clear
成员携程如下的形式:
class Window_mgr{
public:
// 窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
// 按照编号将指定的 Screen 重置为空白
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i){
// s 是一个 Screen 的引用,指向我们想清空的那个屏幕
Screen& s = screens[i];
// 将那个选定的 Screen 重置为空白
s.contents = string(s.height * s.width, ' ');
}
友元关系不存在传递性。也就是说,如果
Window_mgr
由它自己的友元,则这些友元并不能理所当然地具有访问Screen
的特权
令成员函数作为友元
class Screen{
// Window_mgr::clear 必须在 Screen 之前被声明
friend void Window_mgr::clear(ScreenIndex);
// Screen 类剩余的部分
};
- 首先定义
Window_mgr
类,其中声明clear
函数,但是不能定义它。在clear
使用Screen
的成员之前必须声明Screen
类- 接下来定义
Screen
,包括对于clear
的友元声明- 最后定义
clear
,此时它才可以使用Screen
的成员
友元声明和作用域
类和非成员函数的声明不是必须在它们作为友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定改名字在当前作用域是可见地。然而,友元本身不一定真的声明在当前作用域中
甚至就算再类的内部定义该函数,我们也不许再类地外部提供相应地声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元地类地成员调用该友元函数,它也不许是被声明过的。
struct X{
friend void f() { /* 友元函数可以定义再类的内部 */ }
X(){ f(); } // 错误, f 还没有被声明
void g();
void h();
};
void X::g() { return f(); } // 错误:f 还没有被声明
void f(); // 声明那个定义在 X 中的函数
void X::h() { return f(); } // 正确: 现在 f 的声明再作用域中了
这段代码重要的在于理解友元声明的作用是影响访问权限,本身并非普通意义上的声明
构造函数初始值列表
就对象的数据成员而言,初始化和赋值也有类似的区别。如果没有在构造函数的初始值列表显式地初始化成员,则该成员将在构造函数体之前执行默认初始化
Sales_data::Sales_data(const string& s, unsigned cnt, double price){
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
这个版本是对数据成员执行了赋值操作。这一区别到底会有什么影响完全依赖于数据成员地类型
两个版本你的区别类似于:
// 初始化版本
int a = 1;
// 赋值版本
int a; // 会发生默认初始化
a = 1;
构造函数的初始值有时必不可少
有时可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是 const
或者引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
class ConstRef{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int& ri;
};
和其他常量对象或者引用一样,成员 ci
和 ri
都必须被初始化。因此,如果我们没有位它们提供构造函数初始值的话将引发错误:
// 错误: ci 和 ri 必须被初始化
ConstRef::ConstRef(int ii){
// 赋值
i = ii; // 正确
ci = ii; // 错误: 不能给 const 赋值
ri = i; // 错误:ri 没被初始化
}
构造函数时最先执行的,一旦构造函数体开始执行,初始化就已经完成了。我们初始化
const
或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式为:
// 错误: ci 和 ri 必须被初始化
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i){ }
如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值
成员初始化的顺序
成员初始化顺序与它们在类定义中出现的顺序一致。
默认实参和构造函数
class Sales_data{
public:
// 定义默认构造函数,令其与只接受一个 string 实参的构造函数功能相同(意思是同时是默认构造函数和一个接收一个参数的构造函数)
Sales_data(std::string s = ""):bookNo(s){}
...
};
int main(){
Sales_data s1(); // 成功
Sales_data s2("abc"); // 成功
}
委托构造函数( 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): Sales_data()
{ read(is, *this); }
...
};
接收 istream&
的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受委托的构造函数执行完后,接着执行 istream&
构造函数体的内容。它的构造函数体调用 read
函数读取给定的 istream
。
在 Sales_data
类中,接收 string
的构造函数和接受 istream
的构造函数分别定义了从这两种类型向 Sales_data
隐式转化的规则
string null_book = "9-999-99999-9";
// 构造一个临时的 Sales_data 对象
// 该对象的 units_sold 和 revenue 等于 0,bookNo 等于 null_book;
item.combine(null_book);
在这里我们用一个 string
实参调用了 Sales_data
的 combine
成员。该调用是合法的,编译器用给定的 string
自动创建了一个 Sales_data
对象。新生成的这个(临时) Sales_data
对象被传递给 combine
。因为 combine
参数是一个常量引用,所以可以给该参数传递一个临时量
抑制构造函数定义的隐式转换
可以通过将构造函数声明为 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&);
};
此时,没有任何构造函数能用于隐式地创建 Sales_data
对象,之前地用法无法通过编译
item.combine(null_book); // 错误:string 构造函数时 explicit 的
item.combine(cin); // 错误,istream 构造函数时 explicit 的
explicit
关键字支队一个实参的 构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为 explicit
。只能在类内声明构造函数时使用该关键字,在类外部定义时不应重复
// 错误,explicit 关键字只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is){
read(is, *this);
}
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(=)。此时。我们只能使用直接初始化而不能使用 explicit
构造函数
Sales_data item1(null_book); // 正确:直接初始化
//错误: 我们不能将 explicit 构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
当用
explicit
关键字声明构造函数时,它将只能以直接初始化的形式使用。
为转换显示地使用构造函数
尽管编译器不会将 explicit
的构造函数用于隐式转换过程,但是可以使用这样的构造函数显示地强制进行转换:
// 正确:实参是一个显示构造函数 Sales_data 对象
item.combine(Sales_data(null_book));
// 正确:static_cast 可以使用 explicit 的构造函数
item.combine(static_cast<Sales_data>(cin))
类的静态成员
class Account{
public:
void calculate(){ amount += amount * interestRate; }
static double rate(){ return interesRate; }
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};
类的静态成员存在于任何对象之外,对象中不包含任何与静态成员有关的数据。因此,每个 Account
对象将包含两个数据成员:owner
和 amount
。只存在一个 interestRate
对象而且它被所有 Account 对象共享。
类似的,竞态成员函数也不与任何对象绑定在一起,它们不包含 this
指针。作为结果,静态成员函数不能被声明成 const
的,而且哦我们也不能在 static
函数体内使用 this
指针。这一限制既适用于 this
的显式使用,也对调用非静态成员的隐式使用有效果 。
和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static 关键字则只出现在类内部的声明语句中
静态成员能用于某些场景,而普通成员不能
静态成员独立于任何对象。因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。比如静态数据成员可以是不完全类型地。特别地,静态数据成员地类型可以就是它索数地类类型。而非静态数据成员则收到限制,只能声明称它所属类地指针或引用
Class Bar{
public:
// ...
private:
static Bar mem1; // 正确:静态成员可以是不完全类型
Bar* mem2; // 正确:指针成员可以是不完全类型
Bar mem3; // 错误:数据成员必须是完全类型
};
静态成员和普通成员地另外一个区别是我们可以使用静态成员作为默认实参
class Screen{
public:
// bkground 表示一个在类中稍后定义地静态成员
Screen& clear(char = bkground);
private:
static const char bkground;
};
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这样的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。