zl程序教程

您现在的位置是:首页 >  后端

当前栏目

C++基础知识要点--类(Primer C++ 第五版 · 阅读笔记)

C++基础知识笔记 -- 阅读 要点 primer
2023-09-14 09:14:59 时间

C++基础知识要点–类(Primer C++ 第五版 · 阅读笔记)

类的基本思想是 数据抽象( data abstraction)和 封装(encapsulation)。

  1. 数据抽象是一种依赖于 接口(interface)和 实现(implementation)分离的编程(以及设计)技术。
    • 类的接口包括用户所能执行的操作;
    • 类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
  2. 封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

类要想实现数据抽象和封装,需要首先定义一个 抽象数据类型 (abstract data type)。

  • 在抽象数据类型中,由类的设计者负责考虑类的实现过程;
  • 使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。

1、定义抽象数据类型

1.1 定义Sales_data类

成员函数的声明必须在类的内部,他的定义则既可以在类的内部也可以在类的外部

structsales_data {
	/新成员:关于sales_data对象的操作
	std::string isbn() const { return bookNo; }
	Sales_data& combine (const sales_data& ) ;
	double avg price() const;
	//数据成员和2.6.1节(第64页)相比没有改变
	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&);

成员函数通过一个名为this 的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如,如果调用

total.isbn()

则编译器负责把total的地址传递给isbn的隐式形参this.

this 的总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this 中保存的地址。

引入 const 成员函数

isbn函数的另一个关键之处是紧随参数列表之后的 const 关键字,这里,const的作用是修改隐式this指针的类型。

  • 默认情况下,this的类型是指向类类型非常量版本的常量指针
  • 意味着(在默认情况下)我们不能把this绑定到一个常量对象上。

常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

定义一个返回 this 对象的函数

函数combine的设计初衷类似于复合赋值运算符+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数:

sales_data& Sales_data::combine (const Sales_data &rhs)
{
	units_sold += rhs.units_sold; // 把rhs的成员加到this对象的成员上
	revenue += rhs.revenue;
	return *this;            	//返回调用该函数的对象
}

该函数一个值得关注的部分是它的返回类型返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个sales_data的对象,所以返回类型应该是sales_data&。

1.2 定义类相关的非成员函数

我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。

  • 如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。
  • 在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

1.3 构造函数

每个类都分别定义了它的对象被初始化的方式,

  • 类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数( constructor)。
  • 构造函数的任务 是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

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

某些类不能依赖于合成的默认构造函数:

  • 第一个原因:只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数。
  • 第二个原因:如果类包含有内置类型或者复合类型(比如数组和指针)的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
  • 第三个原因:有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化

默认构造函数的定义:(= default)

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) , revenue(p*n) {}

构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

1.4 拷贝、赋值和析构

值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string 的类能避免分配和释放内存带来的复杂性。

进一步讲,如果类包含vector或者string 成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或者赋值操作时,vector类会设法拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁vector对象,也就是依次销毁vector中的每一个元素。这一点与string是非常类似的。

2、访问控制与分装

在C++语言中,我们使用访问说明符 (access specifiers)加强类的封装性:

  • 定义在 public 说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
  • 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。

使用class或struct关键字:

使用 class和struct定义类唯一的区别就是默认的访问权限不同。

  • 如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;
  • 相反,如果我们使用class关键字,则这些成员是private的。

2.1 友元

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:

class sales_data {
//为sales_data的非成员函数所做的友元声明
friend sales_data add (const sales_data&, const sales_data&);
friend std::istream &read(std::istream&, sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
//其他成员及访问说明符与之前一致
}

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。

一般来说,最好在类定义开始或结束前的位置集中声明友元。

3、类的其他特性

其他特性包括:类型成员、类的成员的类内初始值、可变数据成员、内联成员函数、从成员函数返回*this、关于如何定义并使用类类型及友元类的更多知识。

3.1 类成员再探

如果提供了一个构造函数,所以编译器将不会自动生成默认的构造函数。如果我们的类需要默认构造函数,必须显式地把它声明出来。我们可以使用=default 告诉编译器为我们合成默认的构造函数。

令成员作为内联函数

在类中,常有一些规模较小的函数适合于被声明成内联函数。如我们之前所见的,定义在类内部的成员函数是自动inline的。因此,screen的构造函数和返回光标所指字符的get函数默认是inline函数。

我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:

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 row = r * width;                 //计算行的位置
	return contents[row + c];         //返回给定列的字符
}

可变数据成员
有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员, 即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。

一个可变数据成员( mutable data member)永远不会是const,即使它是const对象的成员。因此,一个 const成员函数可以改变一个可变成员的值。举个例子,我们将给screen添加一个名为 access_ctr的可变成员,通过它我们可以追踪每个Screen的成员函数被调用了多少次:

class Screen {
public:
	void some_member() const;
private:
	mutable size_t access_ctr;    //即使在一个const对象内也能被修改
};
void Screen::some_member() const
{
	++access_ctr;          //保存一个计数值,用于记录成员函数被调用的次数
	//该成员需要完成的其他工作
}

尽管some member是一个const成员函数,它仍然能够改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括const函数在内都能改变它的值。

3.2 返回 *this 的成员函数

class Screen {
public:
	Screen &set(char);
	Screen &set(pos, pos, char); //其他成员和之前的版本一致
};
inline Screen &Screen::set (char c){
	contents[cursor] = c;    //设置当前光标所在位置的新值
	return *this;            //将this对象作为左值返回
}
inline Screen &Screen::set(pos r, pos col, char ch)
{
	contents [r*width + col] = ch;   //设置给定位置的新值
	return *this;                    //将this对象作为左值返回
}

和move操作一样,我们的set 成员的返回值是调用set的对象的引用。返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。

从const成员函数返回*this
从逻辑上来说,显示一个screen并不需要改变它的内容,因此我们令diplay为一个const成员,此时,this将是一个指向const的指针而*this是const对象。由此推断,display的返回类型应该是const Sales_data&。然而,如果真的令diplay返回一个const 的引用,则我们将不能把display嵌入到一组动作的序列中去:

Screen myScreen;
//如果display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('*');

即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于display的const版本返回的是常量引用,而我们显然无权set一个常量对象。

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

3.3 类类型

  • 即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿。
struct First {
	int memi;
	int getMem() ;
};
struct Second {
	int memi ;
	int getMem() ;
} ;
First obj1;
Second obj2 = obj1;      //错误:obj1和obj2的类型不同

3.4 友元再探

我们可以把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

  • 如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
class Screen{
   //Window_mgr的成员可以访问Screen类的私有部分
   friend class Window_mgr;
};

必须要注意的一点是,友元关系不存在传递性。也就是说,如果 Window_mgr有它自己的友元,则这些友元并不能理所当然地具有访问screen的特权。

4、类的作用域

4.1 名字查找与类的作用域

在目前为止,我们编写的程序中,名字查找(name lookup)(寻找与所用名字最匹配的声明的过程)的过程比较直截了当:

  • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
  • 如果没找到,继续查找外层作用域
  • 如果最终没有找到匹配的声明,则程序报错。

对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现得不太明显。类的定义分两步处理:

  • 首先,编译成员的声明。
  • 直到类全部可见后才编译函数体。

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

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

typedef double Money ;
class Account {
public:
	Money balance() { return bal; }   //使用外层作用域的Money
private:
	typedef double Money;        //错误:不能重新定义Money
	Money bal;
	// ...
};

类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:

  • 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
  • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
  • 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。

一般来说,不建议使用其他成员的名字作为某个成员函数的参数。

类作用域之后,在外围的作用域中查找

如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。在我们的例子中,名字height定义在外层作用域中,且位于Screen的定义之前。然而,外层作用域中的对象被名为height的成员隐藏掉了。因此,如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求:

//不建议的写法: 不要隐藏外层作用域中可能被用到的名字
void screen::dummy_fcn(pos height) {
cursor = width * ::height;              //哪个height? 是那个全局的

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

5、构造函数再探

5.1 构造函数初始值列表

构造函数的初始值有时必不可少

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

class ConstRef {
public:
	ConstRef(int ii);
private:
	int i;
	const int ci ;
	int &ri ;
} ;

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

如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

建议:使用构造函数初始值

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

除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。

成员初始化的顺序

  • 构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
  • 成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

默认实参和构造函数

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

5.2 委托构造函数

C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数( delegating constructor)。

  • 一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
  • 和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

举个例子,我们使用委托构造函数重写sales_data类,重写后的形式如下所示:

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); }
	//…
};

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在 Sales_data类中,受委托的构造函数体恰好是空的。

假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。

5.3 默认构造函数的作用

当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

  • 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

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

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
  • 当我们不使用初始值定义一个局部静态变量时。
  • 当我们通过书写形如T( )的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。

类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。

在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

5.4 隐式的类类型转换

曾经介绍过C++语言在内置类型之间定义了几种自动转换规则。同样的,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数( converting constructor)。

抑制构造函数定义的隐式转换

在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 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关键字只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is)
{
	read (is, *this);
}

explicit构造函数只能用于直接初始化

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

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

当我们用 explicit 关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。

5.5 聚合类

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

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数。

例如,下面的类是一个聚合类:

struct Data {
	int ival;
	string s;
};

我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员, 初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。下面的例子是错误的:

// val1.ival = 0; val1.s = string("Anna")
Data val1 = {0, "Anna"};

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

值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:

  • 要求类的所有成员都是public的。
  • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
  • 添加或删除一个成员之后,所有的初始化语句都需要更新。

5.6 字面值常量类

我们提到过constexpr函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。

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

  • 数据成员都必须是字面值类型。
  • 类必须至少含有一个constexpr构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式 ;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

6、类的静态成员

有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。

声明静态成员

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

举个例子,我们定义一个类,用它表示银行的账户记录:

class Account {
public:
	void calculate(){ amount t= amount * interestRate; }
	static double rate(){ return interestRate; }
	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的显式使用,也对调用非静态成员的隐式使用有效。

使用类的静态成员

我们使用作用域运算符直接访问静态成员:

double r;
r = Account::rate(); //使用作用域运算符访问静态成员

虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:

Account acl;
Account *ac2 = &ac1;
//调用静态成员函数rate的等价形式
r=ac1.rate ();    //通过Account的对象或引用
r=ac2->rate();         //通过指向Account对象的指针

成员函数不用通过作用域运算符就能直接使用静态成员:

class Account {
public:
	void calculate() { amount += amount * interestRate; }
private:
	static double interestRate;//其他成员与之前的版本一致
};

定义静态成员

和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:

void Account::rate(double newRate){
	interestRate = newRate;
}

和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字则只出现在类内部的声明语句中。

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。

  • 这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。
  • 相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,—个静态数据成员只能定义一次。
  • 类似于全局变量,静态数据成员定义在任何函数之外。区此一旦它被定义,就将一直存在于程序的整个生命周期中。

我们定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字:

//定义并初始化一个静态成员
double Account::interestRate = initRate () ;

要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

静态成员的类内初始化

通常情况下,类的静态成员不应该在类的内部初始化。

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

例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度:

class Account{
public:
	static double rate(){ return interestRate; }
	static void rate(double); 
private:
	static constexpr int period = 30;     // period是常量表达式
	double daily_tbl[period] ;
};

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

例如,如果period 的唯一用途就是定义daily tbl的维度,则不需要在Account外面专门定义period。此时,如果我们忽略了这条定义,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到该成员的定义语句。举个例子,当需要把Account: :period传递给一个接受const int&的函数时,必须定义period。

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

?/一个不带初始值的静态成员的定义
constexpr int Account::period;   //初始值在类的定义内提供

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