C++基础(八):类

本文为《C++ Primer》的读书笔记

目录

类的基础知识

设计Sales_data

Sales_data的接口应该包含以下操作:

  • isbn成员函数, 用于返回对象的ISBN编号
  • combine成员函数, 用于将一个Sales_data对象加到另一个对象上
  • add函数, 执行两个Sales_data对象的加法
  • read函数, 将数据从istream读入到Sales_data对象中。返回流参数的引用
  • print函数, 将Sales_data对象的值输出到ostream。返回流参数的引用

这里的 对象类的实例

首先将使用接口函数的代码写出来,方便之后的实现:

Sales_data total; 			//保存当前求和结果的变量
if (read(cin, total)) 		//读入第一笔交易
{ 
	Sales_data trans; 		//保存下一条交易数据的变量
	while(read(cin, trans)) //读入剩余的交易
	{ 
		if (total.isbn() == trans.isbn()) 	//检查isbn
			total.combine(trans); 			//更新变量total当前的值
		else {
			print(cout, total) << endl; //输出结果
			total = trans; 				//处理下一本书
		}
	}
	print(cout, total) << endl;			//输出最后一条交易
}else {
	cerr << "No data?!" << endl;
}

定义Sales_data

struct Sales_data{
	// 成员函数
	std::string isbn() const { return bookNo; }
	Sales_data& combine(const Sales_data&);
	double avg_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&);

类名的首字符一般用大写字母

类类型

  • 每个类定义了唯一的类型。对于两个类来说, 即使它们的成员完全一样, 这两个类也是两个不同的类型。例如:
struct First {
	int i;
};
struct Second {
	int i;
};
First obj1;
Second obj2 = obj1; // 错误: objl和obj2的类型不同
  • 我们可以把类名作为类型的名字使用, 从而直接指向类类型。也可以把类名跟在关键字classstruct后面 (从C语言继承而来):
Sales_data item1;
class Sales_data item1; //一条等价的声明

类的声明 (前向声明)

我们也能仅仅声明类而暂时不定义它:

class Screen; // Screen类的声明
  • 这种声明有时被称作前向声明(forward declaration), 它向程序中引入了名字Screen并且指明Screen是一种类类型
  • 对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type)

不完全类型只能在非常有限的情景下使用:

  • 可以定义指向这种类型的指针或引用
  • 也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数

  • 对于一个类来说, 在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则, 编译器就无法了解这样的对象需要多少存储空间
    • 因此, 一个类的成员类型不能是该类自己
    • 然而, 一旦一个类的名字出现后, 它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针
  • 类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟, 如果类尚未定义,编译器也就不清楚该类到底有哪些成员

成员函数

  • 成员函数的声明必须在类的内部, 它的定义则既可以在类的内部也可以在类的外部
  • 定义在类内部的函数是隐式的inline函数
  • 函数是所有对象共有的。每个对象的函数成员都通过指针指向同一个代码空间

类内初始值

C++11新标准规定,可以为数据成员提供类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化

  • 类内初始值必须使用 =拷贝初始化形式
  • 或者花括号括起来的列表初始化形式(给类类型的类成员设定类内初始值)
struct SalesData{
	double revenue = 0.0;
	std::vector<int> a{1};
};

SalesData a, b;

也可以如下同时定义类和对象(不推荐):

struct SalesData{
	double revenue = 0.0;
	std::vector<int> a{1};
}a, b;

this 指针

std::string isbn() const { return bookNo; }
  • 关于isbn函数一件有意思的事情是: 它是如何获得bookNo成员所依赖的对象的呢?

让我们再一次观察对isbn成员函数的调用:

total.isbn()

当我们调用成员函数时, 实际上是在替某个对象调用它

  • 成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时, 用请求该函数的对象地址初始化this
    • 例如, 如果调用total.isbn(), 则编译器负责把total的地址传递给isbn的隐式形参this, 可以等价地认为编译器将该调用改写成了如下的形式:
// 调用`Sales_data`的`isbn`成员时传入了`total`的地址
Sales_data::isbn(&total)
  • this 是一个指向调用成员函数的对象的常量指针(this指向的对象不变)
  • 任何对类成员的直接访问都被看作this的隐式引用。因此,在成员函数内部,我们可以直接使用调用该函数的对象的成员, 而无须通过成员访问运算符来做到这一点

任何自定义名为this的参数或变量的行为都是非法的

  • 也可以 显式使用 this 指针
std::string isbn() const { return this->bookNo; }
  • 我们也可以通过 this 指针返回当前对象的引用
    • 例如,Sales_data 类中的 combine 函数,设计初衷是模仿复合赋值运算符+=。为了与它保持一致,combine函数必须返回引用类型:
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
	units_sold += rhs.units_sold; 	//把rhs的成员加到this对象的成员上
	revenue += rhs.revenue;
	return *this; 					//返回调用该函数的对象
}

const 成员函数

std::string isbn() const { return bookNo; }
  • 上面定义语句中, const 的将this指针的类型改为 指向常量的常量指针。使用const的成员函数被称作常量成员函数(const member fonction)
  • 可见,const 成员函数不能调用类内其他的非const成员函数

  • 默认情况下,this的类型是指向类类型非常量版本的常量指针。因此,在默认情况下 我们 不能把this绑定到一个常量对象上。这也就使得我们不能在一个常量对象上调用普通的成员函数
  • 因此,如果在函数中不改变this所指的对象,那么最好使用 const 成员函数,将this设置为指向常量的指针。这样有助于提高函数的灵活性

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

类作用域 和 成员函数

值得注意的是,即使bookNo 定义在isbn之后,isbn也还是能够使用bookNo

这是因为编译器分两步处理类:

  • 首先编译成员的声明
  • 然后才轮到成员函数体(如果有的话)

因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序

在类的外部定义成员函数

  • 如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性
  • 类外部定义的成员的名字必须包含它所属的类名。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的:
double Sales_data::avg_price() const {
	if(units_sold)
		return revenue / units_sold;
	else
		return 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&};

定义readprint 函数

//输入的交易信息包括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;
}
  • readprint分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用
  • print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行

定义add 函数

Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
	Sales_data sum = lhs; 
	sum.combine(rhs);
	return sum;
}

常量对象

const 类名 对象名;
类名 const 对象名;
  • 常量对象在定义时应该进行初始化
  • 只能调用类中 const成员函数

动态分配

  • new 建立对象时自动调用构造函数
  • delete 删除对象时自动调用析构函数

对象数组

Person employee[5] = {
	Person("张立三",25), 
	Person("王冠之",28), 
	Person("王大成",35), 		
	Person("英乐乐",21), 
	Person("胡忠厚",26)
};

构造函数 (constructor)

构造函数:用于初始化类对象的一个或几个特殊的成员函数,在创建对象时被自动调用

  • 构造函数的名字和类名相同没有返回类型
  • 类可以通过重载包含多个构造函数
  • 构造函数不能被声明成const

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

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

Sales_data 的构造函数

对于我们的Sales_data类来说,我们将使用下面的参数定义4个不同的构造函数:

  • 一个istream&, 从中读取一条交易信息
  • 一个const string&, 表示ISBN编号; 一个unsigned, 表示售出的图书数量;以及一个double, 表示图书的售出价格
  • 一个const string&, 表示ISBN编号; 编译器将赋予其他成员默认值
  • 一个空参数列表(即默认构造函数)
struct Sales_data {
	//新增的构造函数
	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) { }
	Sales_data(std::istream &);
	//之前已有的其他成员
	std::string isbn() const { return bookNo; }
	Sales_data& combine(const Sales_data&);
	double avg_price() const;

	std::string bookNo;
	unsigned units_sold = 0;
	double revenue = 0.0;
};

默认构造函数

  • 默认构造函数(default constructor) 无须任何实参。也就是说,当我们定义一个不需要传递任何实参的构造函数时,该构造函数即为默认构造函数 (可以用默认实参来实现)
  • 当对象被 默认初始化值初始化 时自动执行默认构造函数

默认初始化 在以下情况下发生:

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

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

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

  • 类必须包含一个默认构造函数以便在 值初始化 / 默认初始化 下使用, 其中的大多数情况非常容易判断。不那么明显的一种情况是类的某些数据成员缺少默认构造函数:
class NoDefault {
public:
	NoDefault(const std::string&);
	//还有其他成员, 但是没有其他构造函数了
};

struct A { 		
	NoDefault my_mem;
};
A a;		//错误· 不能为A合成构造函数

struct B {
	B() {}	//错误: b_member 没有初始值
	NoDefault b_member;
};

合成的默认构造函数

  • 当我们没有为类定义任何构造函数时,编译器会自动创建一个 合成的默认构造函数(synthesized default constructor)

对于大多数类来说, 这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在 类内初始值, 用它来初始化成员
  • 否则, 默认初始化 该成员
    (因此,在用合成的默认构造函数创建对象时,如果某个成员没有类内初始值,且创建的不是全局对象或静态对象,则对象的初始值是不确定的)

某些类不能依赖合成的默认构造函数,合成的默认构造函数只适合非常简单的类。对于一个普通的类来说, 必须定义它自己的默认构造函数, 原因有三:

  1. 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数
  2. 对于某些类来说, 合成的默认构造函数可能执行错误的操作
    例如, 如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化, 则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此, 含有内置类型或复合类型成员的类应该在类的内部初始化这些成员, 或者定义一个自己的默认构造函数。否则, 用户在创建类的对象时就可能得到未定义的值
  3. 有的时候编译器不能为某些类合成默认的构造函数
    例如, 如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数, 那么编译器将无法初始化该成员。对于这样的类来说, 我们必须自定义默认构造函数, 否则该类将没有可用的默认构造函数

= default

C++11 新标准

  • 如果我们在自己定义了其他的构造函数之后还想让编译器生成一个默认构造函数,就可以使用 = default
Sales_data() = default;
  • = default 既可以和声明一起出现在类的内部 (隐式内联), 也可以作为定义出现在类的外部
Sales_data::Sales_data() = default;

默认实参 和 构造函数

  • 如果一个构造函数为所有参数都提供了默认实参 (不提供实参也能调用上述的构造函数), 则它实际上也定义了默认构造函数
//定义默认构造函数, 令其与只接受一个string实参的构造函数功能相同
Sales_data(std::string s = "") : bookNo(s) { }

  • 当构造函数有默认实参时,要特别注意防止产生调用的二义性
    例如,在定义了上面这个函数之后,需要删掉默认构造函数 Sales_data()=default;,否则会引起调用的二义性

使用默认构造函数

Sales_data obj(); 						//正确: 定义了一个函数而非对象
if (obj.isbn() == Primer_5th_ed.isbn()) 	//错误: obj是一个函数

问题在于, 尽管我们想声明一个默认初始化的对象,obj实际的含义却是一个不接受任何参数的函数并且其返回值是Sales_data类型的对象。正确的方法是去掉对象名之后的空的括号对:

//正确: obj是个默认初始化的对象
Sales_data obj;

构造函数初始值列表

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) { }

上述代码中,冒号以及冒号和花括号之间的代码我们把新出现的部分称为构造函数初始值列表(constructor initialize list)

  • 为新创建的对象的一个或几个数据成员赋初值

  • 构造函数初始值是成员名字的一个列表, 每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来

  • 当某个数据成员被构造函数初始值列表忽略时, 它将以与合成默认构造函数相同的方式隐式初始化 (如果有类内初始值,则使用类内初始值初始化,否则进行默认初始化)

上面的两个构造函数中函数体都是空的。这是因为这些构造函数的唯一目的就是为数据成员赋初值, 一旦没有其他任务要执行, 函数体也就为空了

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

Sales_data::Sales_data(const String &s,
						unsigned cnt, double price)
{
	bookNo = s;
	units_sold = cnt;
	revenue = cnt * price;
}
  • 如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化 (如果该成员有类内初始值,则使用类内初始值,否则进行默认初始化)
  • 上述代码和我们之前定义的版本的效果是相同的: 当构造函数完成后, 数据成员的值相同。区别是原来的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。区别类似于下面的例子:
string foo = "Hello World!"; //定义并初始化

string bar;					//默认初始化成空string对象
bar = "Hello World!";		//为bar赋一个新值

有时我们可以忽略数据成员初始化和赋值之间的差异, 但并非总能这样

  • 如果成员是const或者是引用的话, 必须将其初始化
  • 类似的, 当成员属于某种类类型且该类没有定义默认构造函数时, 也必须将这个成员初始化
class ConstRef {
public:
	ConstRef(int ii);
private:
	int i;
	const int ci;
	int &ri;
};
// 错误: ci和m必须被初始化
ConstRef::ConstRef(int ii)
{	//赋值:
	i = ii;		//正确
	ci = ii;	//错误: 不能给const赋值
	ri = i;		//错误: ri没被初始化
}

我们初始化const 或者引用类型的数据成员的唯一机会就是通过构造函数初始值, 因此该构造函数的正确形式应该是:

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

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

  • 在很多类中, 初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值
  • 除了效率问题外更重要的是, 一些数据成员必须被初始化

成员初始化的顺序

  • 成员的初始化顺序与它们在类定义中的出现顺序一致: 第一个成员先被初始化, 然后第二个, 以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序
  • 最好令构造函数初始值的顺序与成员声明的顺序保持一致
  • 如果可能的话,尽量避免使用某些成员初始化其他成员

一般来说, 初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的, 那么这两个成员的初始化顺序就很关键了,例如:

class X {
	int i;
	int j;
public:
	//未定义的: i在j之前被初始化
	X(int val) : j(val) , i(j) {}
};

有的编译器具备一项比较友好的功能,即当构造函数初始值列表中的数据成员顺序与这些成员声明的顺序不符时会生成一条警告信息

在类的外部定义构造函数

Sales_data(std::istream &);
  • 构造函数没有返回类型,所以定义从我们指定的函数名字开始
Sales_data::Sales_data(std::istream &is)
{
	read(is, *this); 
}

委托构造函数

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("", 0, 0) {}
	Sales_data(std::string s): Sales_data(s, 0, 0) {}
	//委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数
	Sales_data(std::istream &is): Sales_data()	{ read(is, *this}; }
	//其他成员与之前的版本一致
};

转换构造函数 (隐式的类类型转换)

  • 如果构造函数只接受一个实参,则它实际上定义了一条从构造函数的参数类型向类类型隐式转换的规则。有时我们把这种构造函数称作转换构造函数(converting constructor)

例如,在Sales_data类中, 接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data 隐式转换的规则。也就是说, 在要使用Sales_data的地方, 我们可以使用string或者istream作为替代:

string null_book = "9-999-99999-9";
// 构造一个临时的Sales_data对象
// 该对象的unts_sold和revenue等于0, bookNo等于null_book
item.combine(null_book);
item = null_book; // 自动类型转换
  • 编译器用给定的string自动创建了一个Sales_data对象。新生成的这个(临时)Sales_data 对象被传递给combine

只允许一步类类型转换

编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则, 所以它是错误的:

//错误: 需要用户定义的两种转换:
//(1)把"9-999-99999-9"转换成string
//(2)再把这个(临时的) string转换成Sales_data
item.combine("9-999-99999-9")

如果我们想完成上述调用, 可以显式地把字符串转换成string或者Sales_data对象

item.combine(string("9-999-99999-9"));
item.combine(Sales_data("9-999-99999-9"));
item.combine((Sales_data)"9-999-99999-9")

类类型转换不是总有效

Sales_data &combine(Sales_data&);

对于上面的函数,如果仍然用类类型转换来调用则会报错:

Sales_data i;
string s("9-999-99999-9");

i.combine(s)	// 编译无法通过

这是因为无法将临时对象 (右值) 传给非常量左值引用,应该修改为Sales_data &combine(const Sales_data&);

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

  • 可以通过将构造函数声明为explicit 来抑制构造函数定义的隐式转换
  • 只能在类内声明构造函数时使用explicit, 在类外部定义时不应重复

explicit只对一个实参的构造函数 (或除了第一个参数外,其余参数都有默认实参) 有效。需要多个实参的构造函数不能用于执行隐式转换, 所以无须将这些构造函数指定为explicit

例如,接受一个容量参数的vector 构造函数是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&);
	//其他成员与之前的版本一致
};
//错误: explicit只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is)
{
	read(is, *this);
}

  • 尽管编译器不会将explicit的构造函数用于隐式转换过程, 但是我们可以使用这样的构造函数显式地强制进行转换
//正确: 实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
//正确: static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));

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

  • 发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时, 我们只能使用直接初始化而不能使用explicit构造函数:
Sales_data item1(null_book); //正确: 直接初始化
//错误:不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;

访问控制与封装

到目前为止, 我们已经为类定义了接口, 但并没有任何机制强制用户使用这些接口。我们的类还没有封装, 也就是说, 用户可以直达Sales_data 对象的内部并且控制它的具体实现细节


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

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

再一次定义Sales_data类, 其新形式如下所示:

class Sales_data {
public: 
	Sales_data() = defaultSales_data(const std::string &s, unsigned n, double p):
				bookNo(s), units_sold(n), revenue(p*n) { }
	Sales_data(const std::string &s): bookNo(s) { }
	Sales_data(std::istream&);
	std::string isbn() const { return bookNo; }
	Sales_data &combine(const Sales_data&);
private: 
	double avg_price() const
		{ return units_sold ? revenue / units_sold : 0; }
	std::string bookNo;
	unsigned units_sold = 0;
	double revenue = 0.0;
};
  • 一个类可以包含0个或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定
  • 每个访问说明符指定了接下来的成员的访问级别, 其有效范围直到出现下一个访问说明符或者到达类的结尾处为止

使用 classstruct

我们可以使用这两个关键字中的任何一个定义类。唯一的区别是, structclass的默认访问权限不太一样


类可以在它的第一个访问说明符之前定义成员, 对这种成员的访问权限依赖于类定义的方式

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

出于统一编程风格的考虑, 当我们希望定义的类的所有成员是public 的时, 使用struct; 反之, 如果希望成员是private 的, 使用class

structclass在涉及继承时,还有公有继承和私有继承的区别,这个在之后讲解

友元

  • 将其他类或者函数声明为 友元(friend) 即可允许它们访问该类的非公有成员
    • 可以把普通的非成员函数定义成友元
    • 把其他的定义成友元
    • 其他类(之前已定义过的)的成员函数定义成友元
  • 只需要增加一条以friend 关键字开始的友元声明语句即可

  • 友元声明只能出现在类定义的内部,在类内出现的具体位置不限,但一般来说,最好在类定义开始或结束前的位置集中声明友元
  • 友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数 / 类声明。如果我们希望类的用户能够调用某个友元函数或使用某个友元类,那么我们就必须在友元声明之外再专门进行一次声明
  • 友元关系不存在传递性
    例如,若类 C C C 是类 B B B 的友元,类 B B B 是类 A A A 的友元,则 类 C C C 不是类 A A A 的友元

友元类

  • 声明友元类不需要事先将友元类声明或定义
class Screen {
	//Wndow_mgr 的成员可以访问Screen 类的私有部分
	friend class Window_mgr;
};

友元函数 (非成员函数)

友元不是类的成员也不受它所在区域访问控制级别的约束

  • 友元函数能定义在类的内部,这样的函数是隐式内联
    (定义在内部的友元函数通常也需要额外进行声明,但如果该友元函数形参类型为该类,则不需要额外声明。具体可参考这篇博客中“友元声明与实参相关的查找”一节)
  • 声明非成员函数的友元函数同样不需要事先将友元函数声明或定义
class 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&); 

	// ...
};

Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&); 

友元函数 (其他类的成员函数)

  • 当把另一个类的成员函数声明成友元时, 我们必须明确指出该成员函数属于哪个类,且该成员函数必须是公有成员函数
  • 注意:不同于之前的友元类和非成员函数的友元函数,对于成员函数的友元函数而言,因为在友元声明过程中使用作用域运算符取得类内成员,因此不能使用不完全类型,一定要提前进行类的定义
class Window_mgr {
public:
	void clear(Screenindex);
};

class Screen {
	// Window_mgr::clear必须在 Screen 类之前被声明
	friend void Window_mgr::clear(Screenindex);
};

要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在上述例子中, 我们必须按照如下方式设计程序:

  • 首先定义Window_mgr类, 其中声明clear函数, 但是不能定义它。在clear使用Screen的成员之前必须先声明Screen
  • 接下来定义Screen, 包括对clear的友元声明
  • 最后定义clear, 此时它才可以使用Screen的成员

C++不允许将某个类的 构造函数、析构函数 和 虚函数 声明为友元函数

函数重载 和 友元

尽管重载函数的名字相同, 但它们仍然是不同的函数。因此, 如果一个类想把一组重载函数声明成它的友元, 它需要对这组函数中的每一个分别声明

友元声明 和 作用域

  • 类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时, 我们隐式地假定该名字在当前作用域中是可见的。然而, 友元本身不一定真的声明在当前作用域中

  • 甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说, 即使我们仅仅是用声明友元的类的成员调用该友元函数, 它也必须是被声明过的:

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的声明在作用域中了

关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明

类成员再探

作为例子,定义一对相互关联的类, ScreenWindow_mgr

  • Screen表示显示器中的一个窗口。每个Screen包含一个保存Screen内容的string成员和3个string::size_type类型的成员, 它们分别表示光标的位置以及屏幕的高和宽
class Screen {
	friend class Window_mgr;
public:
	typedef std::string::size_type pos;		// 类型成员
	
	Screen() = default; 
	// cursor 成员隐式地使用了类内初始值
	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);		//显式内联
	// 移动光标
	Screen &move(pos r, pos c);				//能在之后被设为内联
private:
	pos cursor = 0;
	pos height = 0, width = 0;
	std::string contents;
};
  • Window_mgr 为一个窗口管理类,用于表示显示器上的一组Screen
class Window_mgr {
public:
	using Screenindex = std::vector<Screen>::size_type;
	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, ' ');
}

类型成员

  • 除了定义数据和函数成员之外, 类还可以自定义某种类型在类中的别名
  • 由类定义的类型名字和其他成员一样存在访问限制, 可以是public或者private中的一种
  • 用来定义类型的成员必须先定义后使用。因此, 类型成员通常出现在类开始的地方 (还有一个原因,见 “名字查找”-“类型名要特殊处理” 小节)
class Screen {
public:
	typedef std::string::size_type pos;
private:
	pos cursor = 0;
	// ...
};

内联成员函数

  • 在类内定义的成员函数是隐式内联的
  • 也可以在类的内部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]; 	//返回给定列的字符
}
  • 虽然我们无须在声明和定义的地方同时说明inline, 但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline, 这样可以使类更容易理解

可变数据成员

  • 有时会发生这样一种情况, 我们希望即使是在一个const 成员函数内,也能修改类的某个数据成员 。可以通过在变量的声明中加入 mutable 关键字做到这一点
  • 一个可变数据成员(mutable data member) 永远不会是const, 即使它是const 对象的成员。因此, 一个const 成员函数可以改变一个可变成员的值
class Screen {
public:
	void some_member() const;
private:
	mutable size_t access_ctr; 	// 可变成员
	// ...
};

void Screen::some_member() const
{
	++access_ctr; 	//保存一个计数值, 用于记录成员函数被调用的次数
}

  • E f f e c t i v e   C + + Effective\ C++ Effective C++ 中,提到了两个概念:bitwise constnesslogical constness

bitwise constness

  • 成员函数只有在不更改对象之任何成员变量(static 除外)时才可以说是 const 。也就是说它不更改对象内的任何一个 bit;这也是之前一直介绍的 const 成员函数的意义
  • 不幸的是许多成员函数虽然不十足具备 const 性质却能通过 bitwise 测试,例如:
class CTextBlock {
public:
	char& operator[] (std::size_t position) const 
	{ return pText[position]; } 
private:
	char* pText;
};
const CTextBlock cctb("Hello"); // 声明一个常量对象。
char* pc = &cctb[0]; // 调用const operator[] 取得一个指针,指向 cctb 的数据
*pc = 'J'; 		// cctb 现在有 "Jello" 这样的内容

logical constness

  • 上述情况就导出了 logical constness;它表示一个 const 成员函数可以修改它所处理的对象内的某些bits, 但只有在客户端侦测不出的情况下才可以如此
  • 然而编译器坚持的是 bitwise constness,因此在这种情况下就需要使用 mutable 关键字
class CTextBlock {
public:
	std: :size_t length() const;
private:
	char* pText;
	mutable std::size_t textLength;	// 高速缓存(cache) 文本区块的长度以便应付询问
	mutable bool lengthisValid;		// 目前的长度是否有效
};

std::size_t CTextBlock::length() const
{
	if (!lengthisValid) {
		textLength = std::strlen(pText); 
		lengthisValid = true; 
	}
	return textLength;
}

返回 *this 的成员函数

class Screen {
public:
	Screen &set(char);
	Screen &set(pos, pos, char);
	// ...
} ;

// 设置光标所在位置的字符
inline Screen &Screen::set(char c)
{
	contents[cursor] = c;
	return *this;
}

// 设置其他任一给定位置的字符
inline Screen &Screen::set(pos r, pos col, char ch)
{
	contents[r * width + col] = ch;
	return *this;
}

set成员的返回值是调用set的对象的引用。如果我们把一系列这样的操作连接在一条表达式中的话:

//把光标移动到一个指定的位置, 然后设置该位置的宇符值
myScreen.move(4, 0).set('#');

如果我们令moveset返回Screen而非Screen&, 则上述语句的行为将大不相同。在此例中等价于:

Screen temp = myScreen.move(4,0);	//对返回值进行拷贝
temp.set('#');						//不会改变myScreen 的contents

  • 继续添加一个名为diplay的操作, 它负责打印Screen的内容。我们希望这个函数能和move以及set出现在同一序列中,因此 diplay函数也应该返回执行它的对象的引用

  • 从逻辑上来说, 显示一个Screen并不需要改变它的内容,因此我们diplay为一个const成员

  • 此时, this将是一个指向const的指针而*thisconst对象。由此推断,diplay 的返回类型应该是const Screen&。然而,如果真的令diplay返回一个const的引用, 则我们将不能把display嵌入到一组动作的序列中去:

// 如果display 返回常量引用, 则调用set将引发错误
// 即使`myScreen`是个非常量对象, 对`set`的调用也无法通过编译
myScreen.display(cout).set('*');

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

基于 const 的重载

和非成员函数一样,成员函数也可以被重载通过区分成员函数是否是const的, 我们可以对其进行重载

具体说来, 是因为非常量版本的函数对于常量对象是不可用的, 所以我们只能在一个常量对象上调用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;}
	//其他成员与之前的版本一致
} ;
  • 当一个成员调用另外一个成员时,this指针在其中隐式地传递。因此, 当display调用do_display时,它的this指针隐式地传递给do_display。而当display的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针

  • do_display完成后,display函数各自返回解引用this所得的对象。在非常量版本中,返回一个普通的(非常量)引用;而const成员则返回一个常量引用

Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout);	//调用非常量版本
blank.display(cout);				//调用常量版本

建议:对于公共代码使用私有功能函数
有些读者可能会奇怪为什么我们要费力定义一个单独的do_display函数。毕竟,对do_display的调用并不比do_display函数内部所做的操作简单多少。为什么还要这么做呢?实际上我们是出于以下原因的:

  • 避免在多处使用同样的代码
  • 我们预期随着类的规模发展, display函数有可能变得更加复杂, 此时, 把相应的操作写在一处而非两处的作用就比较明显了
  • 这个额外的函数调用不会增加任何开销。因为我们在类内部定义了do_display,所以它隐式地被声明成内联函数

在实践中, 设计良好的C++代码常常包含大量类似于do_display的小函数, 通过调用这些函数, 可以完成一组其他函数的“ 实际“ 工作

对象成员 (has-a)

  • 如果一个类 B B B 含有其他类 A A A 的对象(实例),那么
    • 在构造类 B B B 的对象过程中,系统首先调用其对象成员( A A A类)的构造函数,初始化对象成员;然后才执行类 B B B 自己的构造函数,初始化类中的非对象成员
    • 对于同一类中的多个对象成员,系统按照它们在类中的说明顺序调用相应的构造函数进行初始化

当某种类型的对象内含它种类型的对象,就称为 “has-a” 或 “is-implemented-in-terms-of” 关系

class Student {
public: 
	Student() { cout << "构造函数Student()" << endl; } 
};

class Teacher {
public: 
	Teacher() { cout << "构造函数Teacher()" << endl; } 
};

class Tourpair {
public:
	Tourpair() { cout << "构造函数Tourpair()" << endl; }
private:
	Student student; 
	Teacher teacher;
};

int main(){
	Tourpair tp;
	return 0;
}

output:

构造函数Student()
构造函数Teacher()
构造函数Tourpair()

  • 也可以在类 B B B 的构造函数初始化列表中显式调用 A A A 的相应构造函数 (如果类 A A A 没有默认构造函数,则必须这么做)
class Student
{
private:
    string name; 
public:
    Student(const string &pName = "No name") : name(pName) {
        cout << "构造Student: " << name << endl;
    }
    Student(const Student& s) : name(s.name + "的克隆") {
        cout << "拷贝构造Student: " << name << endl; 
    }
    ~Student() {
        cout << "析构Student: " << name << endl;
    }
};

class Tutor
{
private:
    Student student; 
public:
    Tutor(Student& s) :student(s) // 调用Student的拷贝构造函数
    {
        cout << "构造Tutor" << endl;
    }
    ~Tutor() {
        cout << "析构Tutor" << endl;
    }
};

int main(int argc, char *argv[])
{
    Student st1; 
    Student st2("LiHai");
    // 此处调用Tutor的构造函数Tutor(Student &s)
    // 在构造tutor对象的过程中,用初始化表调用
    // Student类的拷贝构造函数Student(Student &s)
    Tutor tutor(st2);
    
    return 0;
}

output:

构造Student: No name
构造Student: LiHai
拷贝构造Student: LiHai的克隆
构造Tutor
析构Tutor
析构Student: LiHai的克隆
析构Student: LiHai
析构Student: No name

析构函数调用的顺序为构造函数调用顺序的逆过程

类的作用域

每个类都会定义它自己的作用域。在类的作用域之外,

  • 普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问
  • 类类型成员则使用作用域运算符访问
Screen::pos ht = 24, wd = 80;		//使用 Screen 定义的 pos 类型
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get();					
c = p->get();						

  • 一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名
    • 在类的外部, 成员的名字被隐藏起来了。一旦遇到了类名, 定义的剩余部分就在类的作用域之内了, 这里的剩余部分包括参数列表和函数体。结果就是, 我们可以直接使用类的其他成员而无须再次授权了

例如:

// 编译器在处理参数列表之前已经明确了我们当前正位于`Window_mgr`类的作用域中,
// 所以不必再专门说明 `ScreenIndex` 和 `screens` 是`Window_mgr`类定义的
void Window_mgr::clear(ScreenIndex i)
{
	Screen &s = screens[i];
	s.contents = string(s.height * s.width, ' ');
}
  • 另一方面, 函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时, 返回类型中使用的名字都在类的作用域之外。这时, 返回类型必须指明它是哪个类的成员
class Window_mgr {
public:
	//向窗口添加一个Screen, 返回它的编号
	Screenindex addScreen(const Screen&);
	//其他成员与之前的版本一致
};

Window_mgr::Screenindex
Window_mgr::addScreen(const Screen &s)
{
	screens.push_back(s);
	return screens.size() - 1;
}

名字查找

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

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

对于定义在类内部的成员函数来说, 解析其中名字的方式与上述的查找规则有所区别。类的定义分两步处理:

  1. 编译成员的声明
  2. 直到类全部可见后才编译函数体

按照这种两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理, 所以它能使用类中定义的任何名字


用于类成员声明的名字查找

  • 这种两阶段的处理方式只适用于成员函数中使用的名字
  • 声明中使用的名字, 包括返回类型或者参数列表中使用的名字, 都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字, 则编译器将会在定义该类的作用域中继续查找。例如:
typedef double Money;
string bal;

class Account {
public:
	Money balance() { return bal; } // 返回名为`bal`的成员, 而非外层作用域的`string`对象
private:
	Money bal;
	// ...
};
  • 当编译器看到balance函数的声明语句时, 它将在Account 类的范围内寻找对Money的声明。编译器只考虑Account中在使用Money前出现的声明,因为没找到匹配的成员,所以编译器会接着到Account的外层作用域中查找

类型名要特殊处理

  • 一般来说, 内层作用域可以重新定义外层作用域中的名字, 即使该名字已经在内层作用域中使用过
struct A {
    void func() { int x = 1, y = 2;  cout << x << y << A::x << A::y; }
    int x = 3;
    int y = 4;
};
A a;
a.func(); // output: 1234
  • 唯一需要注意的是,在类中, 如果成员使用了外层作用域中的某个名字, 而该名字代表一种类型, 则类不能在之后重新定义该名字
typedef double Money;

class Account {
public:
	Money balance() { return bal; }		//使用外层作用域的Money
private:
	typedef double Money;				//错误: 不能重新定义Money
	Money bal;
	//...
};
  • 因此,类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后

成员函数中的名字查找

成员函数中使用的名字按照如下方式解析:

  • 首先, 在成员函数内查找该名字的声明
  • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑
  • 如果类内也没找到该名字的声明, 在成员函数定义之前的作用域内继续查找
    (当成员定义在类的外部时, 不仅要考虑类定义之前的全局作用域中的声明, 还需要考虑在成员函数定义之前的全局作用域中的声明)

一般来说, 不建议使用其他成员的名字作为某个成员函数的参数。不过为了更好地解释名字的解析过程, 我们不妨在dummy_fcn函数中暂时违反一下这个约定:

int height; 	
class Screen {
public:
	typedef std::string::size_type pos;
	void dummy_fcn(pos height) {	
		cursor = width * height; 	// 形参 height
	}
private:
	pos cursor = 0;
	pos height = 0, width = 0;	
}
  • 在此例中,height参数隐藏了同名的成员。如果想我们需要的是类成员, 可以显示使用 this 指针作用域运算符
void Screen::dummy_fcn(pos height) {
	cursor = width * this->height;		// 成员height
	// 另外一种表示该成员的方式
	cursor = width * Screen::height;	// 成员height
}
  • 如果我们需要的是外层作用域中的名字, 可以显式地通过作用域运算符来进行请求:
void Screen::dummy_fcn(pos height) {
	cursor = width * ::height;
}

类的静态成员

  • 有的时候类需要它的一些成员与类本身直接相关, 而不是与类的各个对象保待关联,这时候就可以定义类的静态成员
    • 例如, 一个银行账户类可能需要一个数据成员来表示当前的基准利率。在此例中, 我们希望利率与类关联,而非与类的每个对象关联。从实现效率的角度来看, 没必要每个对象都存储利率信息。而且更加重要的是, 一旦利率浮动, 我们希望所有的对象都能使用新值

  • 类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此一旦它被定义,就将一直存在于程序的整个生命周期中
  • 静态成员函数也不与任何对象绑定在一起, 它们不包含this 指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static 函数体内使用this指针,因此也就不能在静态成员函数中访问非静态成员

声明静态成员

  • 通过在成员的声明之前加上关键字static 使得其与类关联在一起
  • 静态成员可以是public 的或private

// Account 类表示银行的账户记录
class Account {
public:
	void calculate() { amount += amount * interestRate; }
	static double rate() { return interestRate; }
	static void rate(double);
private:
	std::string owner;
	double amount;
	static double interestRate;		// 类内声明,还需提供类外定义
	static double initRate();
};
  • 类的静态成员存在于任何对象之外。因此,每个Account 对象将包含两个数据成员: owneramount ,但只存在一个 interestRate 对象而且它被所有Account 对象共享

使用类的静态成员

  • 可以使用作用域运算符直接访问静态成员:
double r;
r = Account::rate(); 
  • 虽然静态成员不属于类的某个对象, 但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:
Account ac;
Account *pac = &ac;

r = ac.rate();
r = pac->rate();
  • 成员函数不用通过作用域运算符就能直接使用静态成员:
class Account {
public:
	void calculate() { amount += amount * interestRate; }
private:
	static double interestRate;
	// ...
};

定义静态成员

  • 在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:
void Account::rate(double newRate)
{
	interestRate = newRate;
}
  • 因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员
// 定义并初始化一个静态成员
double Account::interestRate = initRate();
  • 从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,我们可以直接使用initRate函数。注意,虽然initRate是私有的,我们也能用它初始化interestRate。和其他成员的定义一样, interestRate的定义也可以访问类的私有成员

静态成员的类内初始化

  • 通常情况下,类的静态成员不应该在类的内部初始化
  • 然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
    • 但即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员;此时如果在类的内部提供了一个初始值, 则成员的定义不能再指定一个初始值

class Account {
public:
	static double rate() { return interestRate };
	static void rate(double);
	static const int num = 0;			// 常量 “声明” 式
private:
	static constexpr int period = 30;	// 常量 “声明” 式
	double daily_tbl[period];
};
  • 注意,上面代码中包含的是 numperiod常量 “声明” 式而非定义式。通常 C++ 要求为变量提供定义式,但如果它是个 class 专属常量又是 static 且为整数类型,则只要不取它们的地址,就可以声明并使用它们而无须提供定义式 (本条规则参考 E f f e c t i v e   C + + Effective\ C++ Effective C++)
// 一个不带初始值的静态成员的定义
constexpr int Account::period; // 初始值在类的定义内提供

静态成员能用于某些场景, 而普通成员不能

  • 静态成员独立于任何对象。因此, 静态数据成员可以是不完全类型
  • 特别的, 静态数据成员的类型可以就是它所属的类类型
class Bar {
public:
	// ...
private:
	static Bar mem1;	// 正确:静态成员可以是不完全类型
	Bar *mem2;			// 正确:指针成员可以是不完全类型
	Bar mem3;			// 错误:数据成员必须是完全类型
};

  • 静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参
  • 非静态数据成员不能作为默认实参, 因为它的值本身属于对象的一部分, 这么做的结果是无法真正提供一个对象以便从中获取成员的值, 最终将引发错误
class Screen {
public:
	// bkground表示一个在类中稍后定义的静态成员
	Screen& clear(char = bkground);
private:
	static const char bkground;
};

聚合类

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

  • 所有成员都是public
  • 没有定义任何构造函数
  • 没有类内初始值
  • 没有基类, 也没有virtual函数
struct Data {
	int ival;
	string s;
};

  • 可以用一个花括号括起来的成员初始值列表初始化聚合类的数据成员
  • 初始值的顺序必须与声明的顺序一致
  • 与初始化数组元素的规则一样, 如果初始值列表中的元素个数少于类的成员数量, 则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量
// val1.ival = 0; val1.s = string ("Anna")
Data val1 = { 0, "Anna" };

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

  • 要求类的所有成员都是public
  • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)
  • 添加或删除一个成员之后, 所有的初始化语句都需要更新

字面值常量类

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


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

constexpr 构造函数

尽管构造函数不能是const的, 但是字面值常量类的构造函数可以是constexpr 函数。事实上, 一个字面值常量类必须至少提供一个constexpr构造函数

  • constexpr 构造函数可以声明成=default 的形式(或者是删除函数的形式)
  • 否则, constexpr构造函数就必须既符合构造函数的要求(意味着不能包含返回语句), 又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句

综合这两点可知, constexpr 构造函数体一般来说应该是空的。我们通过前置关键字constexpr 就可以声明一个constexpr 构造函数了:

class Debug {
public:
	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 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) { hw = b; }
private:
	bool hw;		// 硬件错误, 而非IO错误
	bool io;		// IO错误
	bool other;		// 其他错误
};
  • constexpr 构造函数必须初始化所有数据成员,初始值或者使用constexpr 构造函数,或者是一条常量表达式

  • constexpr 构造函数用于生成constexpr 对象以及constexpr 函数的参数或返回类型:

constexpr Debug io_sub(false, true, false);		//调试IO
if(io_sub.any())
	cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false); 		//无调试
if(prod.any()) 						
	cerr << "print an error message" << endl;

类成员指针

  • 成员指针(pointerto member):指向类的非静态成员的指针。成员指针指示的是类的成员,而非类的对象
    • 类的静态成员不属于任何对象,因此指向静态成员的指针与普通指针没有什么区别

下面以 Screen 为例进行说明:

class Screen {
public:
	typedef std::string::size_type pos;
	char get_cursor() const { return contents[cursor]; }
	char get() const;
	char get(pos ht, pos wd) const;
private:
	std::string contents;
	pos cursor;
	pos height,  width;
};

数据成员指针

  • 与普通指针不同的是, 成员指针必须包含成员所属的类。因此, 我们必须在*之前添加 classname:: 以表示当前定义的指针可以指向 classname 的成员
// pdata可以指向一个常量(非常量) Screen对象的string成员
// 常量对象的数据成员本身也是常量, 因此pdata可以指向任何Screen对象
// 的一个成员, 而不管该Screen对象是否是常量
const string Screen::*pdata;

// 取地址运算符作用于 Screen 类的成员而非内存中的一个该类对象
// 这里假设 contents 为公有成员,否则无法直接指向contents,而必须使用成员函数
pdata = &Screen::contents;

auto pdata = &Screen::contents;

使用数据成员指针

  • 当我们初始化一个成员指针或为成员指针赋值时, 该指针并没有指向任何数据。成员指针指定了成员而非该成员所属的对象, 只有当解引用成员指针时我们才提供对象的信息
Screen myScreen, *pScreen = &myScreen;
// .* 解引用pdata以获得myScreen对象的contents成员
auto s = myScreen.*pdata;
// ->* 解引用pdata以荻得pScreen所指对象的contents成员
s = pScreen->*pdata;

从概念上来说, 这些运算符执行两步操作:

  1. 解引用成员指针以得到所需的成员
  2. 然后像成员访问运算符一样, 通过对象 (.*) 或指针 (->*) 获取成员

返回数据成员指针的函数

  • 常规的访问控制规则对成员指针同样有效
    • 例如,Screencontents成员是私有的, 因此之前对pdata的使用必须位于Screen类的成员或友元内部
  • 因为数据成员一般情况下是私有的, 所以我们通常不能直接获得数据成员的指针。如果一个像Screen这样的类希望我们可以访问它的contents成员, 最好定义一个函数,令其返回值是指向该成员的指针:
class Screen {
public:
	// data是一个静态成员, 返回一个成员指针
	static const std::string Screen::*data()
	{ return &Screen::contents; } // 返回指向Screen类contents成员的指针
	// ...
};
// data()返回一个指向Screen类的contents成员的指针
const string Screen::*pdata = Screen::data();

// 获得myScreen 对象的contents成员
auto s = myScreen.*pdata;

成员函数指针

  • 创建一个指向成员函数的指针, 最简单的方法是使用auto来推断类型:
// pmf是一个指针, 它可以指向Screen的某个常量成员函数
// 前提是该函数不接受任何实参, 并且返回一个char
auto pmf = &Screen::get_cursor;
  • 和指向数据成员的指针一样, 我们使用classname:: *的形式声明一个指向成员函数的指针。指向成员函数的指针也需要指定目标函数的返回类型和形参列表。如果成员函数是const成员或者引用成员, 则我们必须const限定符或引用限定符包含进来。和普通的函数指针类似, 如果成员存在重载的问题, 则我们必须显式地声明函数类型以明确指出我们想要使用的是哪个函数
char (Screen::*pmf2) (Screen::pos, Screen::pos) const;
pmf2 = &Screen::get;
  • 和普通函数指针不同的是,在成员函数和指向该成员的指针之间不存在自动转换规则
// pmf 指向一个 Screen 成员,该成员不接受任何实参且返回类型是 char
pmf = &Screen::get; // 必须显式地使用取地址运箕符
pmf = Screen::get; // 错误:在成员函数和指针之间不存在自动转换规则

使用成员函数指针

Screen myScreen, *pScreen = &myScreen;

// 调用运算符的优先级要高于指针指向成员运算符的优先级,因此括号必不可少
char c1 = (pScreen->*pmf)();
// 通过myScreen对象将实参0, 0传给含有两个形参的get函数
char c2 = (myScreen.*pmf2)(0, 0);

使用成员指针的类型别名

// Action是一种可以指向Screen成员函数的指针, 它接受两个pos实参,返回一个char
using Action =
char (Screen::*) (Screen::pos, Screen::pos) const;
Action get = &Screen::get; // get指向Screen的get成员
  • 和其他函数指针类似,我们可以将指向成员函数的指针作为某个函数的返回类型或形参类型。其中指向成员的指针形参也可以拥有默认实参:
// action接受一个Screen的引用, 和一个指向Screen成员函数的指针
Screen& action(Screen&, Action = &Screen::get);
Screen myScreen;
// 等价的调用:
action(myScreen); 			// 使用默认实参
action(myScreen, get); 		// 使用我们之前定义的变量get
action(myScreen, &Screen::get); // 显式地传入地址

成员指针函数表

  • 对普通函数指针和指向成员函数的指针来说, 一种常见的用法是将其存入一个函数表当中。如果一个类含有几个相同类型的成员,则这样一张表可以帮助我们从这些成员中选择一个
// 假定 Screen 类含有几个成员函数,每个函数负责将光标向指定的方向移动
class Screen (
public:
	// 其他接口和实现成员与之前一致
	// Action是一个指针, 可以用任意一个光标移动函数对其赋值
	using Action = Screen& (Screen:: *)();
	Screen& home();		// 光标移动函数
	Screen& forward();
	Screen& back();
	Screen& up();
	Screen& down();
	enum Directions { HOME, FORWARD, BACK, UP, DOWN };
	Screen& move(Directions);	// `move`可以调用上面的任意一个函数
private:
	// Menu依次保存每个光标移动函数的指针
	static Action Menu[]; 		// 函数表
};	

Screen& Screen::move(Directions cm)
{
	// 运行 this 对象中索引值为 cm 的元素
	return (this->*Menu[cm])(); // Menu [cm] 指向一个成员函数
}

// 按照Directions中枚举成员对应的偏移量存储
Screen::Action Screen::Menu[] = { 
	&Screen::home,
	&Screen::forward,
	&Screen::back,
	&Screen::up,
	&Screen::down,
};
Screen myScreen;
myScreen.move(Screen::HOME); 	// 调用myScreen.home
myScreen.move(Screen::DOWN); 	// 调用myScreen.down

将成员函数用作可调用对象

  • 要想通过一个指向成员函数的指针进行函数调用,必须首先利用.*运算符或->* 运算符将该指针绑定到特定的对象上。因此与普通的函数指针不同, 成员指针不是一个可调用对象

使用 function 生成一个可调用对象

function 介绍在

vector<string> svec;
function<bool (const string&)> fcn = &string::empty;
find_if(svec.begin(), svec.end(), fcn);

vector<string*> pvec;
function<bool (const string*)> fp = &string::empty;
// fp接受一个指向string的指针, 然后使用 ->* 调用empty
find_if(pvec.begin(), pvec.end(), fp);
  • 当我们定义一个function 对象时, 必须指定该对象所能表示的函数类型, 即可调用对象的形式。如果可调用对象是一个成员函数, 则第一个形参必须表示该成员是在哪个( 一般是隐式的) 对象上执行的。同时, 我们提供给function 的形式中还必须指明对象是否是以指针或引用的形式传入
    • 在上面的代码中,我们告诉functionempty 是一个接受string 参数并返回bool 值的函数
    • 又因为 svec 中保存的是 string,因此fcn中指明对象以引用形式传入; svec 中保存的是 string,因此fp中指明对象以指针形式传入
  • 通常情况下, 执行成员函数的对象将被传给隐式的 this 形参。当我们想要使用 function 为成员函数生成一个可调用对象时, 必须首先 “翻译” 该代码, 使得隐式的形参变成显式的
    • 当一个 function 对象包含有一个指向成员函数的指针时, function 类知道它必须使用正确的指向成员的指针运算符来执行函数调用。也就是说, 我们可以认为在 find_if 当中含有类似于如下形式的代码:
// 假设 it 是 find_if 内部的迭代器, 则 *it 是给定范围内的一个对象
if (fcn(*it)) 		// 假设fcn是find_if内部的一个可调用对象的名字

// 从本质上来看, function 类将函数调用转换成了如下形式:
if (((*it).*p)())  	// 假设 p 是 fcn 内部的一个指向成员函数的指针

使用 mem_fn 生成一个可调用对象

要想使用function, 我们必须提供成员的调用形式。我们也可以通过使用标准库功能mem_fn来让编译器负责推断成员的类型

  • function 一样, mem_fn也定义在functional头文件中,并且可以从成员指针生成一个可调用对象
  • function不同的是,mem_fn 可以根据成员指针的类型推断可调用对象的类型,而无须用户显式地指定
find_if(svec.begin(), svec.end(), mem_fn(&string::empty));
  • mem_fn 生成的可调用对象可以通过对象调用,也可以通过指针调用;实际上,我们可以认为mem_fn生成的可调用对象含有一对重载的函数调用运算符: 一个接受string*, 另一个接受string&
auto f = mem_fn(&string::empty); 	// f接受一个string或者一个string*
f(*svec.begin()); 	// 正确:传入一个string对象,f使用.*调用empty
f(&svec[0]); 		// 正确:传入一个string的指针,f使用->*调用empty

使用 bind 生成一个可调用对象

bind 介绍在

  • 我们还可以使用 bind 从成员函数生成一个可调用对象。和 function 类似的是,当我们使用 bind 时, 必须将函数中用于表示执行对象的隐式形参转换成显式的。和 mem_fn 类似的地方是, bind 生成的可调用对象的第一个实参既可以是 string 的指针, 也可以是 string 的引用:
// 选择范围中的每个string, 并将其bind到empty的第一个隐式实参上
auto it = find_if(svec.begin(), svec.end(),
					bind(&string::empty, _1));
auto f = bind(&string: :empty, _1);
f(*svec.begin()); //正确: 实参是一个string, f使用.*调用empty
f(&svec[0]); //正确: 实参是一个string的指针,f使用->*调用empty

嵌套类

  • 嵌套类:定义在另一个类的内部;但外层类的对象和嵌套类的对象是相互独立的
    • 在嵌套类的对象中不包含任何外层类定义的成员;类似的, 在外层类的对象中也不包含任何嵌套类定义的成员
    • 外层类对嵌套类的成员没有特殊的访问权限, 同样, 嵌套类对外层类的成员也没有特殊的访问权限

声明一个嵌套类

class TextQuery {
public:
	class QueryResult; 	// 嵌套类稍后定义
	// ...
};

定义一个嵌套类

  • 和成员函数一样, 嵌套类必须声明在类的内部, 但是可以定义在类的内部或者外部
  • 在嵌套类在其外层类之外完成真正的定义之前, 它都是一个不完全类型
// QueryResult 是 TextQuery 的成员,下面的代码负责定义 QueryResult
class TextQuery::QueryResult {
	friend std::ostream&
		print(std::ostream&, const QueryResult&);
	public:
		// 嵌套类可以直接使用外层类的成员, 无须对该成员的名字进行限定
		QueryResult(std::string,
					std::shared_ptr<std::set<line_no>>,
					std::shared_ptr<std::vector<std::string>>);
		// ...
};

定义嵌套类的成员

// QueryResult 类嵌套在 TextQuery 类中
// 下面的代码为 QueryResult 类定义名为 QueryResult 的成员
TextQuery::QueryResult::QueryResult(string s,
				shared_ptr<set<line_no>> p,
				shared_ptr<vector<string>> f):
			sought(s), lines(p), file(f) {}

嵌套类的静态成员定义

  • 如果 QueryResult 声明了一个静态成员, 则该成员的定义将位于 TextQuery 的作用域之外
// QueryResult 类嵌套在 TextQuery 类中,下面的代码为 QueryResult 定义一个静态成员
int TextQuery::QueryResult::static_mem = 1024;

局部类

  • 局部类定义在某个函数的内部。局部类定义的类型只在定义它的作用域内可见。和嵌套类不同, 局部类的成员受到严格限制
    • 局部类的所有成员(包括函数在内)都必须完整定义在类的内部,因此在实际编程的过程中,局部类的成员函数一般只有几行代码
    • 局部类中也不允许声明静态数据成员, 因为我们没法定义这样的成员

局部类不能使用函数作用域中的变量

  • 局部类只能访问外层作用域定义的类型名、静态变量以及枚举成员。如果局部类定义在某个函数内部, 则该函数的普通局部变量不能被该局部类使用:
int a, val;
void foo(int val)
{
	static int si;
	enum Loc { a = 1024, b };
	// Bar 是 foo 的局部类
	struct Bar {
		Loc locVal;			// 正确:使用一个局部类型名
		int barVal;
		
		void fooBar(Loc l = a)	// 正确:默认实参是 Loc::a
		{
			barVal = val;		// 错误: val 是 foo 的局部变量
			barVal = ::val;		// 正确:使用一个全局对象
			barVal = si;		// 正确: 使用一个静态局部对象
			locVal = b;			// 正确:使用一个枚举成员
		}
	};
}

常规的访问保护规则对局部类同样适用

  • 外层函数对局部类的私有成员没有任何访问特权。当然, 局部类可以将外层函数声明为友元;或者更常见的情况是局部类将其成员声明成公有的。在程序中有权访问局部类的代码非常有限。局部类已经封装在函数作用域中, 通过信息隐藏进一步封装就显得没什么必要了

嵌套的局部类

  • 可以在局部类的内部再嵌套一个类。此时, 嵌套类的定义可以出现在局部类之外。不过, 嵌套类必须定义在与局部类相同的作用域中
  • 局部类内的嵌套类也是一个局部类, 必须遵循局部类的各种规定。嵌套类的所有成员都必须定义在嵌套类内部
void foo()
{
	class Bar {
		public:
			// ...
		class Nested; // 声明 Nested 类
	};
	// 定义 Nested 类
	class Bar::Nested {
		// ...
	};
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值