第7章 类
定义抽象数据类型
定义成员函数
成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。
定义在类的内部的函数是隐式的inline函数。
this指针
成员函数通过一个名为this的额外隐式参数来访问调用它的那个对象。
当我们调用一个成员函数时,用请求该函数的对象地址初始化this。
this是一个常量指针(不允许改变this中保存的地址),this总指向“这个”对象。
const成员函数
在类的成员函数紧随参数列表后的const关键字,它的作用是修改隐式this指针的类型。
使用了const的成员函数被称作常量成员函数(const member function)。
默认情况下,this的类型是指向类类型非常量版本的常量指针(指针本身是常量)。
class A {
/**/
int get_val() { return this->val; } //this是淡色的意在表示它也可以省略
};
则成员函数get_val的this指针类型是A *const(指向非常量的常量指针;即:虽然指针本身是个常量,却“可能通过指针来修改它所指对象的值”);
故(在默认情况下)我们不能把this绑定到一个常量对象上;因而也就使得我们不能在一个常量对象上调用普通的成员函数。
例如:当成员函数get_val是普通的成员函数时:
const A a;
cout<< a.get_val() <<endl; //非法(尽管在用户看来应该是合法的)
以上语句将会编译错误,因为用户创建了一个const的对象a,即规定a是一个常量,其中的成员变量也将是const的,没有可能被改变;
而在调用成员函数get_val时,并未声明其为一个const成员函数,即类内的成员变量val将“有可能”通过this被改变,这是矛盾的。
如果确定成员函数体内不会改变this所指的对象,应该把this设置为指向常量的指针,有助于提高函数的灵活性;此时this指针的类型是const A *const。
class A {
/**/
int get_val() const { return this->val; }
};
例如:当成员函数get_val是const成员函数时:
const A a;
cout<< a.get_val() <<endl; //合法
类作用域和成员函数
编译一个类时,编译器首先编译成员的声明,然后才轮到成员函数体。
因此,成员函数体可以随意使用类中的其它成员而无需在意这些成员的出现次序。
在类的外部定义成员函数:返回类型、参数列表和函数名都得与类内部的声明保持一致;
类外部定义的成员的名字必须包含它所属的类名。
例如:int A::get_val() const { return val; }
合成的默认构造函数
合成的默认构造函数(synthesized default constructor):当没有显示地定义任何构造函数时,编译器创造的构造函数。
合成的默认构造函数对类内的数据成员的初始化规则:
如果存在类内初始值,用它来初始化成员;
否则,默认初始化该成员。
某些类不能依赖于合成的默认构造函数:
(1)只有当类没有声明任何构造函数时,编译器才会自动地生成合成的默认构造函数。
一旦我们定义了一些其它的构造函数,那么除非我们再定义一个默认的构造函数(没有任何形参的构造函数,或者所有的形参都提供了默认实参的构造函数),否则类将没有默认构造函数。
(2)如果类内包含有内置类型或者复合类型(比如数组、指针等)的成员,则只有当这些成员全部都被赋予了类内初始值时,这个类才适合与使用合成的默认构造函数。
因为合成的默认构造函数可能执行错误的操作:对定义在块中的内置类型或者复合类型的对象执行默认初始化,则它们的值是未定义的。
(3)如果类中包含一个其它类类型的成员,且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
对于这样的类,必须自定义默认构造函数。
注:另外还有“阻止拷贝”的情况也将导致编译器无法生成一个正确的合成默认构造函数,这里先不讨论。
=default的含义
struct A {
A() = default;
int i = 0;
string s;
};
C++11中,可以通过在默认构造函数的参数列表后写上=default来要求编译器生成合成的默认构造函数。
此时默认构造函数的作用就完全等同于合成的默认构造函数了。
注意:像示例代码那样,要使用=default特性,我们应该为内置类型的数据成员提供类内初始值。(原因在“合成的构造函数部分”已讨论)
构造函数初始值列表
class A {
A(int a, int b) : c(a), d(b) { }
int c, d, e;
};
当某个成员被构造函数初始值列表(constructor initialize list)忽略时,它将以与合成默认构造函数相同的方式隐式初始化。
(例如这里的e,它没有类内初始值,故将执行默认初始化;否则以类内初始值来初始化)
如果编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
特别地,对const或引用类型的成员,初始化它们的唯一机会就是通过构造函数初始值列表。
(也就是说没办法在构造函数的函数体中使用“=”来赋值:因为不允许对const对象赋值;而引用类型在定义时必须赋初值,完成定义后就不能再次对其赋值)
PS:建议使用构造函数初始值列表。
原因:
(1)必要性(以上讨论的):某些类型的数据成员必须被初始化。
(2)效率问题:赋值相当于先初始化,再向其拷贝一个值的过程,因而赋值的效率低一些。
成员初始化的顺序
最好令构造函数初始值的顺序与成员声明顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其它成员。
例子:
class X {
int i;
int j;
public:
X(int val) : j(val), i(j) { } //未定义的;i在j之前被初始化了
};
注解:构造函数初始值列表只用于说明初始化成员的值,而不限定初始化的具体执行顺序。
此例中,仿佛是先用了val初始化了j,然后再用j初始化了i。实际上,i先被初始化,也就是说试图使用未定义的值j初始化i。
注:对此例,有些编译器会给出警告,有些不会。
使用class或struct关键字
使用class和struct定义类唯一的区别就是默认的访问权限。
class:当未使用访问说明符(access specifier)加以限定时,成员是private的。
struct:当未使用访问说明符加以限定时,成员是public的。
注:当我们希望定义类的所有成员是public时,使用struct;反之使用class。
友元
友元(friend):类可以允许其他类或者(非成员)函数访问它的非公有成员。
如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可。
友元的声明只能出现在类定义的内部,但是在类内出现的具体位置不限。(友元不受它所在区域的访问控制级别约束)
友元不是类的成员。
友元的声明
友元的声明仅仅指定了访问权限,它并非一个通常意义上的函数声明。
必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,通常把友元的声明与类的本身放置在同一个头文件中(类的外部)。
注:某些编译器并未强制限定友元函数必须在使用之前在类的外部声明(就可以调用它),但最好还是提供一个独立的声明函数;
这样即使更换了一个有这种强制要求的编译器,也不必改变代码。
类之间的友元关系
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
class A {
friend class B; //B类的成员可以访问A类的私有部分
};
友元关系不存在传递性。(即,若B有它自己的友元,则这些友元不具有访问A的特权)
令成员函数作为友元
class A {
friend void B::f();
};
此时一般需要仔细组织程序的结构以满足各个类和函数的声明和定义的彼此依赖关系。
封装的优点
(1)确保用户代码不会无意间破坏封装对象的状态。
(2)被封装的类的具体实现可以随时改变,而无需调整用户级别的代码(只要类的接口不变)。
类的其他特性
定义一个类型成员
使用typedef或using定义一个类型成员,必须先定义后使用,因此,类型成员通常出现在类开始的地方。(这一点与普通成员有所区别)
类型成员可以是private或public的。
可变数据成员
使用mutable关键字可声明一个可变数据成员(mutable data member)。
一个可变数据成员永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变数据成员的值。
例如:
class A {
public:
void f() const;
private:
mutable int i;
};
void A::f() const { ++i; }
int main()
{
const A a;
a.f(); //ok
}
尽管f是一个const成员函数,并且a是一个const对象,但f仍然可以改变i的值。
构造函数再探
委托构造函数
C++11允许定义所谓的委托构造函数(delegating constructor)。
class A{
public:
A(int a, int b, string s) : m_data1(a), m_data2(b), m_data3(s) { }
A() : A(0, 0, "") { }
A(int b) : A(0, b, "") { }
A(string s) : A(0, 0, s) { }
};
注:后面三个是不同版本的委托构造函数,委托构造函数的初始值列表只有一个唯一入口,就是类名本身;
它们都把自己的一些或全部职责委托给了第一个构造函数。
使用默认构造函数的一个小错误
试图以如下形式声明一个用默认构造函数初始化的对象:
A obj(); //错误;声明了一个函数而非对象
A obj; //正确;obj是一个对象而非函数
explicit关键字
抑制构造函数定义的隐式转换
将构造函数声明为explicit。
关键字explicit只对一个实参的构造函数有效。
只能在类内声明构造函数时使用explicit关键字,在类外定义时不应重复。
explicit函数只能用于直接初始化
当使用explicit关键字声明构造函数时,它将只能以直接初始化(即:不使用"="的初始化)的形式使用;
而且,编译器将不会在自动转换过程中使用该构造函数。
为转换显式地使用构造函数
尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换。
显式转换具体可通过:
(1)显式构造:A(arg)
(2)static_cast等:static_cast<A>(arg)
聚合类
当一个类满足如下条件时,称其为聚合类(aggregate class):
(1)所有成员都是public的;
(2)没有定义任何构造函数;
(3)没有类内初始值;
(4)没有基类,也没有virtual函数。
聚合类可以用花括号括起来的初始值列表进行初始化。
字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类;
当一个类不是聚合类,但满足如下条件时,则也是字面值常量类:
(1)数据成员都是字面值类型;
(2)类必须至少含有一个constexpr构造函数;
(3)如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
(4)类必须使用析构函数的默认定义。
PS:字面值类型:算术类型、引用、指针、字面值常量类、枚举类型。
constexpr构造函数
尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。
通过在函数名前添加constexpr关键字就可以声明一个constexpr函数。
constexpr构造函数可以声明成=default的形式(或者是删除函数的形式,之后再讨论);
否则,constexpr构造函数就必须既符合constexpr函数的要求(它能拥有的唯一可执行语句就是返回语句),又符合构造函数的要求(不能包含返回语句);
综合以上两点要求可知,constexpr构造函数体一般来说是空的。
类的静态成员
声明静态成员
静态成员可以是public或private的。
类的静态成员存在于任何对象之外。
静态成员函数也不与任何对象绑在一起,它们不包含this指针。
静态成员不能声明成const的,而且我么也不能在static函数体内使用this指针。
使用类的静态成员
(1)使用作用域运算符直接访问静态成员
double r = A::rate(); //rate是类中的一个静态成员函数
(2)虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用、指针来访问静态成员
A a1;
A *a2 = &a1;
r = a1.rate(); //ok
r = a2->rate(); //ok
(3)成员函数不用通过作用域运算符就能直接使用静态成员
定义静态成员
static关键字只出现在类内部的声明语句中,当在类外定义静态成员时,不能重复static关键字。
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被创建的。因此必须在类的外部定义和初始化每个静态成员;
一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员一旦被定义,就将一直存在于程序的整个生命周期。
例子:
class Account {
private:
static double rate;
static double initRate();
};
double Account::rate = initRate();
注意:尽管initRate是私有的,我们也能直接使用它。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,允许为静态成员提供const整数类型的类内初始值(并且初始值必须是常量表达式)。
什么时候适合对静态成员使用类内初始化?
举个例子:
class Account {
private:
static constexpr int period = 30; //PS:constexpr也可以是const
double daily_data[period];
};
像上面一样,如果period的唯一用途就是定义daily_data的维度,则不需要在类的外部专门定义period;
另外的情况则不然,例如,当需要把Account::period传递给一个接受const int&的函数时,必须在类的外部定义period(否则编译错误:程序找不到该成员的定义语句)。
即使一个常量静态数据成员在类的内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
特殊地,要指出的是,如果在类的内部提供了初始值,则成员的定义(类外)就不能再指定初始值了。
constexpr int Account::period; //这是一个不带初始值的静态成员的定义
静态成员与普通成员的区别
(1)静态成员可以是不完全类型。特别地,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则只能声明成它所属类的指针或引用。
class Bar {
private:
static Bar mem1; //ok;静态成员可以是不完全类型
Bar *mem2; //ok;指针成员可以是不完全类型
Bar mem3; //错误;普通的数据成员必须是完全类型
};
(2)可以使用静态数据成员作为默认实参(因为静态成员独立于任何对象)。非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分。
class Screen {
public:
Screen& clear(char = bkground); //PS:函数的声明中可省略变量名
private:
static const char bkground; //PS:需要在类外定义并提供初始值
};