《C++Primer 第五版》——第七章 类
7.0 前言
- 类的基本思想: 数据抽象(data abstraction) 和 封装(encapsulation) 。
- 数据抽象:是一种依赖于 接口(interface) 和 实现(implementation) 分离的编程(以及设计)技术。
- 类的接口: 包含了用户所能执行的操作。
- 类的实现: 包含了类的数据成员,负责接口实现的函数体以及定义类所需的各种私有函数。
- 封装: 实现了类的接口和实现的分离,隐藏了类的实现细节,使得用户只能使用类的接口而无法查看其实现细节。
7.1 定义抽象数据类型
- 成员函数(member function): 声明写在类定义内部的函数,而定义写在类内外都可以。但是写在类定义外的函数名前必须加上
类名::
。
例如:
class Stu
{
public :
void display( )const;
//内部定义成员函数
void show( ){
}
Stu& ret();
private:
string name;
unsigned age;
};
//外部定义成员函数
//注意在函数前要添加 类名::
void Stu::display( ) const
{
cout << " name: " << name << " age: " << age;
}
- 运算程序的人称为用户(user),而使用别人定义的自定义类的程序员其实也是用户。
- 注意:定义在类内的成员函数是隐式的内联(inline)函数。 定义在类外的不是
- 当我们调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个关键字 this 来访问调用它的那个对象, this 同时也是 所有成员函数的隐式参数 。
当对象调用一个成员函数时,用这个对象的地址初始化 this 。
例如调用上例成员函数 display 。
Stu Stu1;//定义一个 Stu1 对象
Stu1.display();
则编译器负责将 Stu1 的地址传递给 display 的隐藏形参 this ,可以等价地理解为编译器将该调用重写成了如下形式:
//其中的 name 和 age 可以理解成写成了 Stu1.name 和 Stu1.age
//可以这么理解,但这只是一个伪代码
Stu::display(&Stu1);
- this 关键字: 通常用于访问对象,而不是单访问对象的某个成员
① this 是 隐式定义 的;
② 默认情况下, this 是一个指向当前对象常量指针,它的类型是指向非常量版本 class 类型的常量指针 (是 top-level const ,但不是 low-level const );
③ 但是在成员函数的参数列表后面添加 const ,则可以修改 this 的类型为指向常量版本 class 类型的常量指针 (既是 top-level const ,也是 low-level const )。
④ 在成员函数中调用属性可以理解为this->属性
,因为 this 是隐式形参,所以一般不会这么写,当然可以显式使用。
⑤ 实际上,任何自定义名为 this 的参数或变量都是非法的。
- 常量成员函数(const member function): 在函数参数列表后添加 const 的成员函数。
因为 this 是成员函数中隐式定义执行类类型非常量版本的 top level-const ,只能通过在成员函数参数列表后添加 const 的方式修改该成员函数中 this 的类型。修改之后,就不能通过 this 修改类的成员变量。
class Date {
public:
void GetYear() const {
y_=12; //报错,此时 this 已经是 low level-const 类型
}
private:
int y_;
};
- C++编译器分两步处理 class 类型:
①首先编译成员的声明;
②然后才编译成员函数体。
- 由第 7 点可知道, 成员函数可以随意使用类中的其他成员而无需在意这些成员出现的次序 。
- 与其他函数一样,在类定义外定义的成员函数时,成员函数的函数头必须与类定义内的函数声明包含相同的返回类型、参数列表、函数名,同时还要在函数名前添加
类名::
(这是为了告诉编译器该方法是属于类定义的作用域内的)。另外,常量成员函数还要在参数列表后面添加 const 关键字。 - 常量对象以及常量对象的引用和指针都只能调用常量成员函数。
- 定义一个返回 this 指向对象的引用的函数,例如:
Stu& Stu::ret()
{
//返回调用该函数的对象的引用
return *this;
//也可以定义一个返回对象本身的函数,
//但是返回对象会涉及到生成返回对象的副本,按需选择返回对象本身还是引用
//Stu Stu::ret1(){ return this; }
}
- 一般来说,如果非成员函数是类接口的组成部分(概念上属于类但实际上并不是成员函数),则这些函数的声明应该与类在同一个头文件内。
- 默认情况下,拷贝类的对象其实是拷贝该对象的数据成员。
- 构造函数(constructor): 类用于初始化对象的成员函数,只要当对象被创建时,构造函数就会执行。
构造函数的调用方法如下:
1.括号法
Student stu1(13,135);
2.隐式调用
Student stu1 = {13,135};
3.显式调用
Student stu1 = Student(13,135);
- 构造函数具有以下特点:
- 没有返回类型 (void不是没有返回类型的意思) ;
- 函数名与类名相同;
- 与其它函数一样具有一个参数列表和函数体(两者可能为空);
- 可以有多个不同的构造函数;
- 不能被声明成常量成员函数(const member function)。
举例:
class CExample {
public:
int a,b;
CExample(){a=0;b=8;}
};
- 默认构造函数(default constructor): 实现对象的默认初始化的特殊构造函数,默认构造函数 不需要实参,所以没有形参 ,当 对象创建时自动执行 。
- 注意: 只有当类没有显式定义构造函数,编译器才会自动为其隐式定义一个默认构造函数。一旦定义了类的构造函数,编译器就不会自动为其定义默认构造函数。此时需要程序员自己定义默认构造函数。
- 合成的默认构造函数(synthesized default constructor): 编译器定义的构造函数。
对于绝大多数类,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果类定义内成员变量存在了初始值,则用它来初始化成员变量
- 否则,使用对应类型的默认初始化来初始化该成员
- 编译器有时候不能为某些类定义默认的构造函数 。
① 当类中包含一个或多个其它类的成员,且该类没有默认构造函数,此时编译器将无法初始化该成员;
② 已经给这个类定义了构造函数;
③ 对于具有引用成员或无法默认构造的 const 成员的类(书P451);
- 在 C++11 新标准中,如果我们需要默认的行为,可以在参数列表后写上
= default
来要求编译器生成默认构造函数。
同样的,= default
既可以和函数声明一起出现在类定义内作为内联函数,也可以和函数定义一起出现在类定义外作为非内联函数。如果编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表来初始化类的每个成员。
CExample() = default;
- 构造函数初始值列表(constructor initialize list) : 冒号以及冒号和花括号之间的代码。
用于为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员变量名字的一个列表,每个名字后面紧跟着括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开。
例如,下面的构造函数与上面的构造函数的结果是相同的。
class CExample {
public:
int a,b;
//构造函数初始化列表
CExample(): a(0),b(8){} // 因为构造函数只是为了初始化类成员,所以这里的函数体为空
};
- 如果自定义的构造函数内没有显式初始化全部的类成员,则这些类成员会执行各自类型的默认初始化。
- 注意: C++ 初始化类成员时,是按照其声明的顺序初始化的,而不是按照出现在初始化列表中的顺序。
- 同样的,构造函数也可以在类外部定义,也要在构造函数名前添加
类名::
。 - 一般来说如果我们不显式定义,编译器将会自动合成类的初始化、拷贝、赋值和销毁操作。 只是对于某些类来说合成的操作无法正常执行,比如管理动态内存的类通常不能依赖于上述合成的操作,但是如果管理动态内存的类使用的是 vector 或 string 这些已经定义好相关操作的类型,则编译器合成的操作可以正常执行。
- 如果一个class类型具有合成的默认构造函数,默认初始化时,先进行0值初始化再调用合成的默认构造函数。
- 如果要创建一个class类型的对象,那么直到构造函数执行完成,即该对象初始化完之后,对象才能获得“常量”属性。所以将构造函数声明为 const 函数是没有意义的,并且会报错。
7.2 访问控制与封装
在C++中,可以使用 访问说明符(access specifiers) 来加强类的封装性:
- 定义在 public 说明符之后的成员在整个程序内可以被访问, public 成员定义类的接口;
- 定义在 private 说明符之后的成员只可以被类的成员函数访问,但是不能被外部代码直接访问, private 部分封装了类的实现细节。
构造函数应该紧跟在 public 关键字后面。
一个类可以包含0个或者多个访问说明符,而且对于某个访问说明符能出现的次数也没有严格规定。每个访问说明符指定了接下来的类成员的访问级别,其 有效范围 直到下一个访问说明符或者达到类的结尾为止。
比如:
class Stu
{
public:
Stu();
~Stu();
private:
int a;
};
默认访问权限 :没有使用访问说明符时的访问权限。
使用关键字 class 定义的类,其默认访问权限是 private 。
使用关键字 struct 定义的结构体,其默认访问权限是 public 。
可以采用以下的类定义方法:
class Stu
{
int a; //此时 a 的访问权限是 private
public:
Stu();
~Stu();
};
在 C++ 中 class 和 struct 定义抽象数据类型的区别 只是默认访问权限不一样 。 C++为 C 中的结构体引入了成员函数、访问控制权限、继承、包含多态等面向对象特性。
7.2.1 友元
类可以允许其他类或者函数访问它的非公有成员(private
和protected
),方法是令 其他 类 或者 函数 成为它的 友元(friend) 。关于友元类,将在7.3.4友元再探具体介绍。
注意:
① 友元函数声明只能出现在类定义的内部,但是在类内出现的具体位置不限。
② 友元函数不是类的成员,也不受访问说明符的限制。
封装的两个重要益处:
1.确保用户代码不会无意间破坏封装对象的状态;
2.被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
tip:
1.一般来说,最好在类定义开始或结束前的位置声明友元;
2.友元函数的声明仅制定了访问权限,而非一个通常意义上的函数声明。所以还要在类定义外再对友元函数编写一次常规的函数声明。
哪怕友元函数定义在类内部,甚至是该类的成员函数调用该友元函数,也还是要在类外声明一次该函数,以保证该函数可见;
3.许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。
4.友元声明和普通声明并不一样,友元声明指明了访问权限,而普通声明指定了可见性。
如何声明类的友元函数:
①在类的定义内添加友元函数的声明;
②并且在函数的声明语句前添加关键字 friendclass Box {//访问控制权限默认是 private friend void printWidth( Box box ); double width; double length; public: void setWidth( double wid ); friend void printLength( Box box ); }; //友元函数的定义和其他非成员函数一样,写在类定义外且不需要添加域操作符 类:: void printWidth( Box box ) { // printWidth() 是 Box 的友元,它可以直接访问该类的任何成员 cout << "Width of box : " << box.width <<endl; }; void printLength( Box box ){ cout << "Length of box : " << box.length <<endl; };
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态;
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
将数据成员设置为 private 有两个好处:
①类作者可以比较自由地修改数据,修改接口实现部分,而不需要改变用户代码,只要接口不变,用户的代码就无须改变。反之,直接使用类成员属性的代码可能会失效。
比如int a = box.width ;
和int a = box.getwidth();
,如果程序员删除了width这个属性或修改其类型,这段语句就会失效,而只要Box的接口 getwidth() 不变(指返回类型、形参列表、函数名构成的函数头),那这段语句就是正确的。
②防止由于用户的原因造成数据被破坏。
7.3 类的其他特性
7.3.1 类成员再探
在类内定义类型别名,可以隐藏类的实现细节。
但是要注意类内定义的类型别名的两个事项:
①和其它类成员一样存在访问限制,可以是 public 或 private ;
②必须先定义后使用,和类成员函数和属性不一样,原因在7.4.1
class Screen
{
public:
typedef string::size_type pos;
//同样可以等价使用别的类型别名定义方式,比如: using pos = string::size_type
private:
//用户不知道 Screen 类是用 string::size_type 保存数据
pos cursor = 0;
pos height = 0, width = 0;
string contents;
};
在构造函数中如果没有显式初始化成员属性,编译器会使用它的 类内初始值 来初始化它。但是如果它不存在类内初始值,则需要我们显式初始化它。
class Screen
{
public:
typedef string::size_type pos;
Screen() = default;
//这里因为 cursor 没有被显式初始化,所以被它的类内初始值0初始化
Screen(pos a, pos b, char c) : width (a), height(b), contents(a*b, c){}
//隐式内联
char get() const{ return contents[cursor]; }
//显式内联
inline char get(pos a, pos b) const ;
Screen& move(pos r, pos c);
private:
//前三个属性都有类内初始值0
pos cursor = 0;
pos height = 0, width = 0;
string contents;
};
在类中,经常会将一些规模较小的成员函数声明成内联函数。
具体方法有三种:
①定义在类内的成员函数是隐式声明成 inline 函数;
②通过 inline 关键字在类内显式声明函数;
③在类外用 inline 修饰函数的定义。
允许一个成员函数同时实现②③两点。
//在 Screen 类内声明为内联函数
char Screen::get(pos r, pos c) const {
pos row = r * width;
return contents[row + c];
}
//在 Screen 类外定义为内联函数
inline Screen& Screen::move(pos r, pos c) {
pos row = r * width;
cursor = row + c;
return *this;
}
成员函数也可以和普通函数一样被重载 , 重载成员函数之间的参数数量和类型必须存在区别,函数匹配机制也与普通函数一样。
有时,我们希望能在 const 修饰的成员函数中修改类的某个数据成员,此时可以通过在类中定义数据成员时使用mutable
关键字修饰该成员变量以实现这目的。
但是mutable
不能和const
一起使用。
class Screen
{
public:
void some_member() const;
private:
//即使在一个 const 对象内也能被修改
mutable size_t access_ctr;
};
void Screen::some_member() const{
++access_ctr;
};
在C++11中,如果希望类拥有一个默认初始化的数据成员,最好的方法是使用类内初始值。
7.3.2 返回 *this 的成员函数
返回引用的函数是左值的——函数返回的是对象本身而非对象的副本。
如果函数返回的不是引用,那么将会返回对象的副本。
比如:
//A返回对象本身,B返回对象的副本
string& A(string& a) { return a; }
string B(string b) { return b; }
结合类的this
,我们可以写出下面函数:
Screen myScreen = Screen();
myScreen.move(4, 1).set('#');
其中move和set函数返回值都是Screen类型,且return语句都返回 *this
,把对象本身当作左值返回。所以可以连续两次.
成员运算符调用函数。等价于下面语句。
myScreen.move(4, 1);
myScreen.set('#')
如果两个函数返回的不是引用,那么等价于下面语句:
//这里的temp对象是编译器自动创建的临时副本,被调用move函数后的myScreen对象拷贝初始化
Screen temp = myScreen.move(4, 1);
temp.set('#');
一个 const 成员函数如果返回*this
,且返回类型是引用类型,则它的返回类型将是常量引用。所以如果 const 成员函数要返回*this
,返回类型必须是常量引用(在返回类型添加 const)。
const Screen& Screen::A() const { return *this; } //如果返回类型不加 const 会报错
注意: 如果 const 成员函数的返回类型是 常量引用 ,且返回值是*this
时,则不能像myScreen.move(4, 1).set('#');
调用会改变this指向对象的数据成员的函数。
这里介绍一下重载函数的另一种形式: 基于 const 成员函数的重载函数
在第六章中介绍了函数一般是通过参数列表的不同来实现重载。但是现在 通过区分成员函数是否是 const 成员函数 ,我们可以在函数列表相同的情况下,对其进行重载。
1.非常量成员函数对于常量对象是不可用的,常量对象只能调用 const 成员函数;
2.非常量对象可以调用 const 成员函数和非 const 成员函数,但非常量版本是更好的匹配。
class Screen
{ //其它成员函数和数据成员和之前的一样
public:
Screen& display(ostream& os) { do_display(os); return *this; }
const Screen& display(ostream& os) const { do_display(os); return *this; }
private:
void do_display(ostream& os) const { os << contents; }
string contents;
};
Screen myScreen(5, 3, 3);
const Screen blank(5, 3, 3);
myScreen.set('#').display(cout); //调用非 const 版本
blank.set('#').display(cout); //调用const版本
当成员函数调用另一个成员函数时,this
指针在其中隐式传递。因此,当display
调用do_display
时,它的this
指针将从指向非常量的指针隐式转换成指向常量的指针。
于是,在非常量版本的display
函数中,this
指向一个非常量对象。相反在常量版本的display
函数中,this
指向一个常量对象。因此display
分别返回一个非常量引用和一个常量引用。
7.3.3 类(class)类型
注意:即使两个不同类(class)或结构体(struct)的成员列表完全一致,它们也是不同的类型。比如:
class First{
int memi;
int getMen();
};
class Second{
int memi;
int getMen();
};
First a; Second b; // a和b的类型不一样
直接使用类名定义对象的方式如下:
Screen A; //主流方式,这里的作用是默认初始化
class Screen B; //等价的默认初始化
前向声明(forward declaration) :其中一种形式为class Screen;
,只声明类而暂时不定义它。它告诉了编译器这是一种类类型,但不清楚它的定义,在它声明之后定义之前是一个不完全类型(incomplete type)。另一种形式就是类的定义class Screen{}
此时可以在{}
中添加一个类型为指向该类的引用或指针的成员。
适用范围:
1.可以定义指向这种类型的指针或引用;
2.声明以不完全类型作为参数或返回类型的函数。
注意:
1.在创建类的对象之前,该类必须被定义过,而不能只是被声明过。 否则编译器无法知道创建这个对象需要多少内存空间(即它的物理大小)。
2.类必须先被定义,然后才能用引用或指针访问其成员。 否则编译器就不知道该类到底有什么成员。
直到类被定义后,数据成员才能被声明成这种类类型,比如常用的string。
简单说, 必须先完成类的定义,然后编译器才能知道储存该数据成员需要多少空间。 因为只有当类全部完成后类才算被定义,所以一个类的数据成员类型不能是其所属的类类型。但是,当类名出现后,它就算是被声明过了(未定义),因此类允许包含指向它自身类型的引用或指针(这也就是前向声明的一个应用场景) 。
class link_screen {
Screeen window;
link_screen *prev; // 前向声明的一个应用场景
link_screen *next;
};
7.3.4 友元再探
除了可以将①普通的非成员函数定义成友元,还可以把②其它类定义成友元,可以把③其他类(已定义)的成员函数定义成友元。此外, 友元函数能定义在类的内部,这样的友元函数是隐式内联的。
友元类、友元函数、友元成员函数使用的注意事项:
1.友元关系不存在传递性,每个类控制自己的友元。如果 Window_mgr 有自己的友元,则它的友元并不具有访问 Screen 全部成员的特权。
2.如果一个类指定了友元类,则友元类的成员可以访问此类包括非 public 成员在内的所有成员。
3.友元成员函数必须在类之前被声明,但不能定义。
声明友元类方式如下:
class Screen{
// Window_mgr 的成员可以访问 Screen 类的非公有成员
friend class Window_mgr;
// Screen 其它成员和之前定义的一样
};
class Window_mgr{
public:
//窗口中每个屏幕的编号,定义 std::vector<Screen>::size_type 的类型别名 ScreenIndex
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 = std::string(s.height * s.width, ' ');
}
声明友元成员函数方式如下:(成员函数前要加上类名::
)
class Screen{
// Window_mgr::clear 必须在 Screen 类前被声明
friend void Window_mgr::clear(ScreenIndex); // ScreenIndex是一个类型别名
};
7.3.4.1 令某个成员函数作为友元成员函数的顺序
类的定义和成员函数的普通声明 > 成员函数的友元声明 > 成员函数的定义
①类的定义和成员函数的普通声明:
以Window_mgr::clear
为例,首先定义Window_mgr
类,并声明clear
成员函数,但不定义。因为clear
函数要想使用Screen
类的成员必须在类声明后才可以被定义,否则无法知道Screen
有哪些成员;
②成员函数的友元声明:
接下来定义Screen
类,并在其中完成Window_mgr::clear
的友元声明;
③成员函数的定义:
最后定义clear
成员函数,此时编译器才知道Screen
有哪些成员,clear
才能访问相关成员。
上例如下:
using ScreenIndex = std::vector<Screen>::size_type;
//先声明成员函数 clear ,但不进行定义
class Window_mgr {
public:
void clear(ScreenIndex);
private:
vector<Screen> screens{ Screen(24, 80, ' ') };
};
//接着对clear函数进行友元声明
class Screen {
public:
Screen(int h, int w, char con) {
height = h, width = w;
contents = con;
}
friend void Window_mgr::clear(ScreenIndex);
private:
int height, width;
string contents;
};
//最后定义Screen类的友元函数Window_mgr::clear
void Window_mgr::clear(ScreenIndex i)
{
Screen& s = screens[i];
s.contents = std::string(s.height * s.width, ' ');
}
7.3.4.2友元类和友元函数的定义
友元类和友元函数的注意事项:
① 如果一个类想把一组重载函数声明成它的友元,则需要对这组重载函数的每一个重载分别进行友元声明。
② 与成员函数不同的是,类和非成员函数的普通声明不是必须在它们的友元声明之前。只要在使用之前被普通声明即可,即符合C++的名字查找规则。
③ 就算在被访问类内定义了友元的非成员函数,也必须在被访问类外部再次声明该函数以保证函数可见。哪怕只是被访问类的成员函数要调用该友元函数,它也必须 已在类外被声明 。
class X {
public:
friend void f(){ /*友元函数可以定义在类内类外*/ }
X(){ f(); } // 报错:未定义标识符 f ,此时 f 在这里不可见
void g();
void h();
};
void X::g(){ return f(); } // 报错:未定义标识符 f ,此时 f 在这里不可见
void f(); // 声明定义在 X 中的函数
void X::h(){ return f(); } // 正确:现在 f 的声明在作用域中了
注意:与7.2.1中tip第3点说的一样,有的编译器并不强制指向上述关于友元的限定规则,这取决于编译器的具体规则。
即有的编译器可能会允许编译通过违反上述规则的代码,一些编译器允许在尚无友元函数的初始声明时就调用它。
7.4 类的作用域
每个类都会定义它自己的作用域。在类的作用域以外,普通的数据和函数成员只能由对象、引用或指针,使用成员访问运算符.
或间接成员运算符->
来访问。类(class)类型则使用作用域运算符::
访问成员。
Screen::pos ht = 24, wd = 80; //类类型使用::访问成员
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get(); //访问 scr 对象的 get 成员
c = p->get(); //访问 p 所指向对象的 get 成员
一个类就是一个作用域,所以在类外部定义成员函数时必须提供类名::函数名
。因为在类外部,成员的名是不可见的。
一旦遇到了类名::
,函数定义的剩余部分(参数列表和函数体)就在类的作用域之内了,所以在这两部分中如果访问类的成员就可以不用再以类名::成员
的方式了。
在之前的clear
的定义中,使用到了出现在Window_mgr类中定义的类型别名的ScreenIndex
,这是因为在函数名前添加了Window_mgr::
,所以编译器就知道这是Window_mgr类的成员函数,所以编译器知道了ScreenIndex
是在Window_mgr类中定义的类型别名。
void Window_mgr::clear(ScreenIndex i)
{
Screen& s = screens[i];
s.contents = std::string(s.height * s.width, ' ');
}
当然,如果不在Window_mgr类外定义成员函数clear
,则不用添加Window_mgr::
编译器也知道ScreenIndex
是在Window_mgr类中定义的类型别名,因为此时clear
是在类Window_mgr的作用域中。
7.4.0 如果在类外定义成员函数,则要注意成员函数返回值类型
以Window_mgr类的一个新成员函数为例:
class Window_mgr {
public:
using ScreenIndex = std::vector<Screen>::size_type;
ScreenIndex addScreen(const Screen &i);
// 其他成员与之前版本一致
};
// 首先处理返回类型,然后才进入Window_mgr的作用域
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &i)
{
screens.push_back(s);
return screen.size() - 1;
}
编译器会先处理返回类型,然后才进入Window_mgr的作用域。所以如果不用Window_mgr::
对返回类型ScreenIndex
进行修饰,则编译器不知道标识符,会报错。
因为返回类型出现在类名之前,所以它位于 类名::函数名
之前,即类的作用域之前。所以如果有需要,必须再次使用类名::
对返回类型进行修饰,指定它是在哪定义的。
注意:使用类名::
不能仅仅只是修饰返回类型,最重要的还是修饰成员函数名。比如:
// 第一个会报错,因为编译器并不知道addScreen是Window_mgr类的成员函数
Window_mgr::ScreenIndex addScreen(const Screen &i){
screens.push_back(s);
return screen.size() - 1;
}
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &i){
// 与上面一致
}
7.4.1 名字查找与类的作用域
名字查找(name lookup): 寻找与所用标识符最匹配的声明的过程。
名字查找的一般过程如下:
- 首先,在该标识符所在的块中寻找其声明语句,且只考虑在使用该标识符之前出现的声明;
- 如果没找到,继续寻找相邻外层作用域;
- 如果最终(即到了最外层作用域)没找到匹配的声明,则程序报错。
不过对于定义在类内部中的成员函数中来说,解析其中标识符的方式与上述的过程有区别。
类的定义分两步编译:
① 首先,编译类成员的声明;
② 直到类全部可见后才编译成员函数体(包括类内定义的函数)。
即 编译器处理完类中的全部声明后才会处理成员函数的定义 。
因为这种两阶段①②的处理方式,成员函数体内才可以使用类中声明的所有(无论在类内哪个位置声明)标识符。
这种两阶段的处理方式只适用于成员函数体中使用的标识符。而成员变量和成员函数的声明中使用的标识符,包括成员函数声明时返回类型或者参数列表中使用的标识符, 都必须在使用前确保可见。 像之前提到的类内定义的类型别名。
如果某个成员的声明使用了类中尚未出现的标识符, 则编译器将会按照名字查找的一般过程进行查找。在定义该类的作用域中继续查找,且只会考虑在使用该标识符之前的声明。如果没找到匹配的成员则会到类的外层作用域继续查找,直到找到或到了最外层作用域还没找到为止。
7.4.2 类型标识符在类的作用域里要特殊处理
一般来说,内层作用域可以重新定义外层作用域的标识符,即使该标识符已经在内层作用域中使用过。
然而在类中,如果成员使用外层作用域的某个名字,而该名字代表一种类型,则类不能再之后重新定义该名字 ,比如:
typedef double Money;
class Account
{
public:
Money balance () //使用外层作用域的Money
{
return bal;
}
private:
typedef double Money; // 错误:不能重新定义Money,虽然在VS里通过了编译
Money bal;
};
尽管重新定义类型标识符是一种错误的行为,但是编译器并不为此负责,一些编译器仍将顺利编译这样的代码。 类型名通常定义在类定义的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义后。
7.4.3 成员函数定义中普通块作用域的名字查找
成员函数中使用的标识符按照如下方式解析:
- 首先,在成员函数内(包括函数的参数列表)查找该标识符的声明,且只有在该标识符使用之前出现的声明才被考虑;
- 如果在成员函数内没有找到,则在相邻外层作用域(即类内)继续查找,此时类的所有声明都可以被考虑;
- 如果类内也没有找到该名字的声明,在成员函数定义之前的作用域内继续查找。
注意:一般来说,不建议使用类的其他成员的标识符作为某个成员函数的参数。
比如下面的例子:
int height;
class Screen
{
public:
typedef string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * height; // 哪个height?
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};
当编译器处理dummy_fcn
函数体的width * height
中的height
时,它首先在该函数的作用域内,表达式cursor = width * height
之前查找关于height
的声明。 而函数的参数位于函数作用域内,所以表达式的height
与函数参数的height
相匹配。这就导致了类成员height
被隐藏了。
如果在成员函数中想要强行访问成员,可使用 类名::
或this->
来强行访问类成员 。
函数的作用域: 函数体+函数参数
7.4.4 类作用域之后,在外围的作用域中查找
如果编译器在函数和类的作用域中都没有找到标识符,他将接着在外围的作用域中查找。
如果需要最外层作用域中的某个标识符,可以显式地通过作用域运算符来请求全局变量:
void Screen::dummy_fcn(pos height) {
cursor = width * ::height; // 哪个height? 是全局变量height
}
强行访问全局变量方法:::全局变量
7.4.5 在文件中标识符的出现处对其进行解析
当类成员定义在类的外部时,名字查找的一般过程中的第三步不仅要考虑类定义之前在全局作用域中的声明,还要考虑到在成员函数定义之前的全局作用域中的声明。 例如:
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 ) {
// var: 参数
//height: 类成员
//verify: 全局函数
height =verify( var );
}
本来全局函数verify
在Screen
类定义之前是不可见的,但是Screen::setHeight
函数除了查找Screen
类定义之前的全局作用域里的声明,还查找了setHeight
函数定义之前的全局作用域里的声明。
7.5 构造函数再探
7.5.1 构造函数初始值列表
就对象的数据成员而言,初始化和赋值也有类似的区别。 如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数之前执行默认初始化。 例如:
// Sale_data 构造函数的一种写法
// 虽然合法但是比较草率:因为不是初始化,而是赋值
Sales_data::Sales_data(const string &s, unsigned cnt, double price)
{
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
与P237的7.1.4中的构造函数初始值列表不一样的是这个版本的构造函数是 对数据成员进行了赋值操作,而不是初始化操作 ,虽然这两种版本构造函数使用之后数据成员的值相同。
有时候可以忽略数据成员的初始化和赋值之间的差异,但并不是总能这样,比如定义一个top-level const或引用变量时,就必须将其初始化。例如:
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
// 错误:ci 和 ri 必须被初始化,而不是赋值
ConstRef::ConstRef(int ii)
{ // VS上也会报错: C2789 必须初始化常量限定类型的对象 C2530 必须初始化引用
// 赋值
i = ii; // 正确
ci = ii; // 错误:不能给 const 赋值
ri = i; // 错误:ri 没被初始化
}
// 正确:显式地初始化引用和 const 成员
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i) { }
如果类成员变量以下类型之一,则必须通过构造函数初始值列表为这些成员提供初始值:
- 是
const
或引用类型- 是某种未提供默认构造函数的类类型
(重要)建议:为什么要使用构造函数初始值列表
1.在很多类中,构造函数是初始化操作还是赋值操作的区别事关底层效率问题:①前者直接初始化数据成员再进入函数体内;②后者则先使用类内初始值(如果有就用)默认初始化再进入函数体内进行赋值。
2.构造函数初始化列表:随着构造函数一开始执行,初始化就完成了,然后才进入函数体。 比直接在函数内部赋值要少了函数体内赋值这一过程。
3.除了效率问题外,更重要的是,一些数据成员必须被初始化。 建议养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。
在构造函数初始值列表中,成员初始化的顺序:
① 在构造函数初始值列表中,每个成员只能出现一次;
② 构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序 ;
③ 成员的初始化顺序与它们在类定义中的声明顺序一致: 第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后关系不会影响实际的初始化顺序。
一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键
class X {
int i;
int j;
public:
X(int val) : j(val), i(j) { } // 未定义的行为:因为 i 在 j 之前被初始化
};
Note: 最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可以的话,尽量避免使用某些成员初始化其他成员,而是使用构造函数的参数作为成员的初始值。这样就不必考虑成员的初始化顺序了。
默认构造函数分为两种:
① 不含有参数的默认构造函数;
② 带有默认参数值的默认构造函数。
class Sales_data {
public:
// 定义带默认参值的数默认构造函数,令其与只接受一个 string 实参的构造函数功能相同
Sales_data(std::string s = "") : bookNo(s) { }
// 其他构造函数与之前一致
Sales_data(std::string s, unsigned cnt, double rev) :
bookNo(s), units_sold(cnt), revenue(rev * cnt) { }
Sales_data(std::istream &is) { read(is, *this); }
// 其他成员与之前的版本一致
};
当没有给定实参,或者给定了一个string实参时,两个版本的默认构造函数创建了相同的对象。因为我们不提供实参也能调用上述的构造函数,所以该构造函数实际上为我们的类提供了默认构造函数。 如果一个构造函数为所有参数都提供了默认实参,那么它实际上也定义了一个默认构造函数。
不能有多个默认构造函数,哪怕是一个带参数一个不带参数也不行:
问: 如果接受string的构造函数和接受istream&的构造函数都使用默认实参,这种行为合法吗?
这种行为不合法,如果为两个构造函数都赋予默认实参,则这两个构造函数都具有了默认构造函数的作用。一旦不提供任何实参地创建类的对象,则编译器无法判断这两个构造函数哪个更好。也就是对函数的调用不明确,从而出现二义性错误。
7.5.2 委托构造函数
C++11 新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。
委托构造函数(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() : Sales_data("", 0, 0) { } //委托构造函数,同时也是默认构造函数
Sales_data(std::string s) : Sales_data(s, 0, 0) { }
// Sales_data的第四个构造函数委托给第二个构造函数(默认构造函数)
Sales_data(std::istream &is) : Sales_data() { read(is, *this) }
// 其余部分相同
};
委托构造函数的执行顺序:
1.当一个构造函数委托给另一个构造函数时,将委托构造函数的参数传入受委托的构造函数初始值列表,并进行初始化操作;
2.执行受委托的构造函数的函数体;
3.然后将控制权转交给委托构造函数的函数体并执行。
7.5.3 默认构造函数的作用
7.5.4 隐式的类(class)类型转换
程序员可以为类定义隐式转换规则——通过定义转换构造函数。
本质: 执行隐式的类(class)类型转换 = 调用转换构造函数
转换构造函数(converting constructor): 如果构造函数只接受一个参数(除了默认形参外),则它实际上定义了从该参数类型转换为此类类型的隐式转换机制。
换句话说,如果一个构造函数接收一个不同于其class类型的实参,可以视为将其实参转换成该class类型的一个对象。把这种构造函数称作转换构造函数。
Note: 只能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向class类型隐式转换的规则。所以在任何需要该class类型的地方,都可以使用该参数类型来替代。
比如:
class NoDefault {
private:
std::string str;
string a;
public:
// 隐式定义从 int 转换为 NoDefault 的隐式转换机制
// NoDefault(string o) : a(o){} 也是转换构造函数
NoDefault(string o, string b="a") : a(o){}
NoDefault() = default;
NoDefault& combine(const NoDefault&) { return *this; }
};
// 在任何需要使用 NoDefault 类型的地方,都可以使用 string 类型作为替代
void ns(const NoDefault&) {}
ns(cc);
这就隐式定义了一个从 string 转换为 NoDefault 的隐式转换机制。所以在任何需要使用 NoDefault 类型的地方,都可以使用 string 类型作为替代。
隐式的类类型转换的执行过程:
1.编译器自动调用与传入实参类型相匹配的转换构造函数,并用该实参创建一个临时量(temporary);
2.将临时量替换到替代的地方。
比如下图,因为向 NoDefault 的成员函数 combine 传入的实参类型 string ,与其转换构造函数的参数类型相同。所以编译器自动地用cc
调用了对应的转换构造函数,创建了一个 NoDefault 对象(临时),并将其传递给成员函数 combine ,因为 combine 所需的参数类型是一个常量引用,所以可以给该函数传递一个临时量。
NoDefault c;
string cc = "66";
c.combine(cc); // 成员函数 combine 的参数类型是 const NoDefault&
编译器一次只允许一步隐式的class类型转换
正如在4.11.2节中提到的,编译器一次只会执行一步隐式的class类型转换。
比如下面的例子就展现了正确的做法和错误的做法:
// 错误:需要执行两次隐式的class类型转换:
// 1. 把 "9-999-99999-9" 从 const char[] 转换成一个临时的 string 对象
// 2. 再把这个临时的 string 对象转换成一个临时的 Sales_data 对象
c.combine("9-999-99999-9");
// 正确的做法是只执行一次隐式的class类型转换:
// 正确:显式地转换成 string,隐式地转换成 Sales_data
c.combine(string("9-999-99999-9"));
// 正确:显式地转换成 Sales_data,隐式地转换成 string
c.combine(Sales_data("9-999-99999-9"));
阻止转换构造函数定义的隐式转换规则
显式构造函数(explicit constructors): 在声明构造函数时使用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&);
// 其他成员与之前一致
};
此时对于Sales_data类来说,此前定义的两种隐式的class类型转换都不能实现。即无法通过string
和istream
两种参数类型调用Sales_data的转换构造函数创建一个临时量。
适用范围:
关键字explicit
只能对仅接受一个参数(除了默认形参外)的构造函数有效,也就是只对转换构造函数有效。只能在类内声明构造函数时使用explicit
关键字,在类外部定义时不应该重复。
需要多个实参的构造函数不是转换构造函数,所以无须将这些构造函数指定为explicit
的,当然将任何构造函数指定为explicit
的是合法的,不会报错。
比如:
class NoDefault {
private:
string str, a;
public:
explicit NoDefault(const std::istream &is);
explicit NoDefault(string o, string b = "a") : a(o) {}
explicit NoDefault() = default; //不会报错
NoDefault& combine(const NoDefault&) { return *this; }
};
// 只能在类内声明构造函数时使用explicit关键字,在类外部定义时再使用会报错。
explicit NoDefault::NoDefault(const istream &is)
{
read(is, thi*s);
}
显式构造函数只能用于直接初始化
另外一种发生隐式的class类型转换的情况是:当执行拷贝初始化时(即使用=
形式的初始化,参考3.2.1节)。
所以对于NoDefault的初始化而言,此时只能使用直接初始化,而不能使用拷贝初始化,即不能使用explicit
修饰过的构造函数:
// 正确:直接初始化
NoDefault item1(cin);
// 错误:不能将 explicit 构造函数用于拷贝形式的初始化过程
// 因为 explicit 阻止了本应发生的隐式的class类型转换
NoDefault item2 = cin;
Note: 当我们用explicit
关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。
显式地使用隐式类类型转换
尽管编译器不会将声明为explicit
的转换构造函数用于隐式的class类型转换过程。但是可以使用下面这种方法显式地强制执行转换。
// 正确:static_cast 可以使用 explicit 的构造函数
item.combine(static_cast<Sales_data>(cin));
在这里我们使用到了4.11.3节的命名的强制类型转换,并选择static_cast
显式执行的本应隐式执行的类类型转换。上例中,使用istream
对象并调用了一个匹配的转换构造函数创建了一个临时的NoDefault
对象。
标准库中含有显式构造函数(Explicit Constructors)的类
比如:
- 除默认形参外,只接受一个单参数类型为
const char*
的string
类的构造函数不是explicit
的。- 接受一个容量参数的
vector
构造函数是explicit
的。(3.3.1)
所以:
string tt = "HAHA"; // 正确,C++的字符串常量是const的char数组
vector<string > vs = 10; // 错误,只能使用直接初始化
7.5.5 聚合类
聚合类(aggregate class): 使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。
当一个类满足以下全部条件时,就称为聚合类:
- 所有数据成员都是
public
的;- 没有定义任何构造函数;
- 没有类内初始值;
- 没有基类,也没有
virtual
函数。
比如:
class Data {
public:
int iVal;
string s;
};
如何初始化聚合类的数据成员: 一个花括号括起来的成员初始值列表,且初始值的顺序必须与声明的顺序一致。
// val1.ival = 0; val1.s = string("Anna");
Data val1 = { 0, "Anna"};
// 错误:不能使用"Anna"初始化val2.ival,也不能使用1024初始化val2.s
Data val2 = { "Anna", 1024};
使用成员初始值列表初始化聚合类的注意事项:
①如果初始值列表中元素个数少于类的成员数量,则靠后的成员被值初始化;
②初始值列表中元素个数不能超过类的成员数量;
显式地初始化类的对象的数据成员存在3个明显的缺点:
- 要求类的成员都是公有的;
- 将正确初始化每个对象的每个数据成员的任务交给了类的用户(而非类的作者)。又因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错;
- 添加或删除一个成员之后,所有的初始化语句(成员初始值列表的部分)都需要更新。
上面“显式地初始化类的对象的数据成员”指的是: 之前提到的初始化聚合类的方式,即使用一个花括号括起来的成员初始值列表来初始化类的对象的数据成员。比如CTest myTest = {1,2};
7.5.6 字面值常量类
在6.5.2中说过,constexpr函数的参数和返回值必须是字面值类型(literal type)。除了算术类型、引用和指针外,某些类也是字面值类型。
和其它类不同, 字面值类型的类可能含有constexpr成员函数 。这些成员函数必须符合constexpr函数的所有要求,并且它们是隐式const(隐式声明为 const 函数)的。
成为字面值常量类的条件
- 数据成员都是字面值类型 的 聚合类 是字面值常量类;
- 如果一个类 不是聚合类 ,但它 符合下述要求 ,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型;
- 类必须至少含有一个 constexpr 构造函数;
- 如果一个数据成员含有类内初始值(in-class initializer),则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型(class type),则初始值必须使用成员自己的 constexpr 构造函数。
- 类必须使用析构函数的默认定义,该成员函数负责销毁类的对象。
constexpr 构造函数
尽管构造函数不能是 const 函数,但是字面值常量类的构造函数可以是 constexpr 函数。事实上,一个字面值常量类必须至少提供一个 constexpr 构造函数,这是一个类被称为字面值常量类的条件之一。
constexpr 构造函数可以声明成= default
(显式地要求编译器生成默认构造函数)或者是 删除函数 = delete
的形式(在13.1.6会提到)。
否则, constexpr 构造函数就必须既符合构造函数的要求,又符合 constexpr 函数的要求(换句话说,除了声明成= default
或= delete
的形式外, constexpr 构造函数的函数体一般是空的)。
例如:
class Debug
{
public:
// constexpr 构造函数的形参可以不是 constexpr 变量,但是实参必须是常量表达式或 constexpr 构造函数
constexpr Debug(bool b = true) : hw(b), io(b), other(b) { } // 通过构造函数初始值列表
constexpr Debug(bool h, bool i, bool o) : hw(h), io(i), other(o) { }
constexpr Debug() : Debug(false) { } // 委托其他 constexpr 构造函数
constexpr bool any() { return hw || io || other; }
void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { other = b; }
private:
bool hw; // 硬件错误,而非IO错误
bool io; // IO错误
bool other; // 其他错误
};
constexpr 构造函数必须初始化所有数据成员。 且数据成员的初始值 (注意是实参) 要么使用 constexpr 构造函数,要么是一条常量(constexpr)表达式 。
constexpr 构造函数的形式:
①通过构造函数初始值列表;
②委托其他 constexpr 构造函数,同时也声明为 constexpr 构造函数;
③将构造函数声明成= default
或= delete
的形式。
constexpr Debug io_sub(false, true, false); // 调试IO
if (io_sub.any()) // 等价于if(true)
cerr << "print appropriate error messages" << endl;
constexpr Debug prod(); // 无调试
if (prod.any()) // 等价于if(false)
cerr << "print an error message" << endl;
7.6 类的静态成员
声明类的静态成员
如何声明类的静态成员: 在成员声明之前加上关键字static
。
和其他成员一样,静态成员可以是public
或private
的。
静态数据成员的类型: 可以是常量、引用、指针、class类型等。
类的静态成员存在于任何本类的对象之外,对象中并不包含任何与静态数据成员有关的数据。但相同类的所有对象都可以共享本类的静态成员。
同理,静态成员函数也不与任何对象绑定,所以静态成员函数不包含this
指针。
因为不包含this
指针,静态成员函数不能声明成 const 函数。
这一限制即适用于 this 的显式使用(比如*this
),也对 this 的隐式使用有效(比如调用非静态数据成员)。
使用类的静态成员
使用类的静态成员的方法:
1.使用作用域运算符直接访问静态成员:类名::静态成员函数(参数列表);
2.虽然静态成员不属于类的任何对象,但仍然可以使用类的对象、引用或者指针来访问静态成员:
对象名.静态成员函数( 参数列表 ); 对象名.静态数据成员;
引用名.静态成员函数( 参数列表 ); 引用名.静态数据成员;
指针->静态成员函数( 参数列表 ); 指针->静态数据成员;
3.成员函数可以不通过作用域运算符就能直接使用静态成员:
比如:
class Account
{
public:
void calculate() { amount += amount * interestRate; }
static double rate() { initRate(); return interestRate; }
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};
定义类的静态成员函数
和其他成员函数一样,既可以在类的内部也可以在类的外部定义静态成员函数。
当在类的外部定义静态成员函数时,不能使用static
关键字,该关键字只能出现在类内部的声明语句:
void Account::rate(double newRate) { interestRate = newRate; }
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。
这意味着静态数据成员不是由类的构造函数初始化的。
虽然 一般来说,不能在类的内部初始化静态数据成员 。但是,必须在类的外部定义每个静态成员(哪怕已经在类内初始化静态数据成员)。
和其它对象一样,一个静态数据成员只能定义一次。
虽然静态数据成员声明在类中,但类似于全局变量的是,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
定义静态数据成员的方式和定义静态成员函数差不多。 我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字。比如:
// 定义并初始化一个静态成员
double Account::interestRate = initRate();
从类名::
开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,可以直接使用私有的 initRate 函数。
和其他成员的定义一样,定义静态成员函数时也可以访问类的私有成员。
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态数据成员的类内初始化
通常情况下,类的静态数据成员不应该在类的内部初始化,但是也有特殊情况。
在《C++ Primer》(第5版)的英文版原文是这样的:
we can provide in-class initializers for static members that have const integral type and must do so for static members that are constexprs of literal type.
在中译版的第270页中,翻译为:
我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。
个人觉得翻译不是那么清晰,自己对英文的理解如下:
我们可以为类型是 const 整型的静态数据成员提供类内初始值,并且必须为 constexpr 字面值类型的静态数据成员提供类内初始值。
静态数据成员的类内初始化的说明如下:
static const
的数据成员可以使用类内初始值来初始化( 不是必须的 ,也可以在类外定义时再初始化),但static constexpr
的数据成员 必须 使用类内初始值来初始化。
也就是说,除了静态常量( const 和 constexpr )成员之外,其他的静态数据成员不能在类的内部初始化,且初始值必须是常量表达式。
class A
{
public:
// 整型的 const 静态数据成员
static const bool b1;
static const char c1;
static const int i1;
// 浮点型的 const 静态数据成员
static const float f1;
static const double d1;
// 整型的 const 静态数据成员
static const bool b2 = false;
static const char c2 = 'b';
static const int i2 = 2;
// 浮点型的 const 静态数据成员
// static const float f2 = 3.5; // 错误:"const float" 类型的成员不能包含类内初始值设定项
// static const double d2 = 4.5; // 错误:"const double" 类型的成员不能包含类内初始值设定项
// 字面值类型的 constexpr 静态数据成员
static constexpr int a1 = 10;
static constexpr double a2 = 10.0;
static constexpr bool a3 = true;
// static constexpr bool a4; // 报错: constexpr 类型的静态数据成员的声明必须要有类内初始值
// char m1[i1];// 错误:i1的常量还未初始化
char m2[i2];
char m3[c2];
};
const bool A::b1 = true;
const char A::c1 = 'a';
const int A::i1 = 1;
const float A::f1 = 1.5;
const double A::d1 = 2.5;
// const bool A::b2; // 报错:重定向,多次初始化
const bool A::b2;
const char A::c2;
const int A::i2;
即使一个常量静态数据成员给予了类内初始值,通常情况下也应该在类的外部定义一下该成员,但不能重复初始化。如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了。
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的 const static
或 constexpr static
不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。(这里的理解可以去看一下我的一篇文章C++中静态常量数据成员在不同标准下的一些区别。
静态成员能用于某些场景,而普通成员不能
静态数据成员独立于任何对象。因此,在某些非静态数据成员可能非法的场合,静态数据成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型(声明但尚未定义的类型)。
因为静态数据成员可以是不完全类型,所以,静态数据成员的类型可以是它所属的class类型,但非静态数据成员不能是它所属的class类型,只能声明成指向它所属class类型的指针或引用。 比如:
class Bar {
public:
// ...
private:
static Bar mem1;// 正确:静态成员可以是不完全类型
Bar *mem2; // 正确
//Bar mem3; // 错误:数据成员必须是完全类型
Bar &mem4; // 正确
string mem5; // 正确:string 类型是已经声明并定义了,不是不完全类型
};
普通成员函数包含this
形参,但因为函数参数的赋值先后顺序是未定的,所以该默认值也是未定的,如int function(class_type *this, int n = this->a); // 实际上没有class_type *this,这是隐式的
,可能先执行第二个参数的赋值,此时的this是未定义的标识符,自然this->a就是错误的。
静态数据成员和普通数据成员的另外一个区别就是可以使用静态数据成员作为函数的默认实参(参见6.5.1):
class Screen {
public:
// bkground表示一个在类中稍后定义的静态成员
Screen& clear(char = bkground);
private:
static const char bkground;
};
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象(即this指针为空)以便从中获取成员的值,最终将引发错误。