【C++】类与对象(末):static、友元与拷贝优化

【C++】类与对象(末):static、友元与拷贝优化


🔥 H.莓飛: 个人主页

❄️个人专栏:「C++」「数据结构」「Linux系统编程」

✨ 心不畏死,踏屍前行。

📦 代码仓库:cocatrice_csdn_code


📖 博主简介:


概览

  • 构造函数体与初始化列表
  • 初始化顺序与未初始化的内置类型成员
  • 类型转换与 explicit
  • static 成员变量与 static 成员函数
  • 静态成员的典型用途
  • 友元函数与友元类
  • 内部类与匿名对象
  • 拷贝时的编译器优化

核心知识

类的六个默认成员函数在前面已经逐个展开过,构造函数怎么写、析构函数什么时候被调用、拷贝构造与赋值重载怎么区分,这几件事都已经落到代码上。这一篇要处理构造函数身上剩下的几个疑问:冒号后面那一串到底做了什么,一个类里有好多个成员时谁先被初始化,只写声明不写初值的 int 成员在读出来的时候是什么值。

剩下的篇幅分给另外几件在写代码时容易出错的事情。单参构造函数会把一个 int 悄悄变成类对象,这个转换有时有用,有时会在重载解析里插一脚。静态成员属于类而不属于对象,它的存储位置与访问方式都和普通成员不同。友元为突破封装而存在,单向、不传递,用一次耦合就多一分。内部类与匿名对象是两处语法糖,前者让关联紧密的两个类贴在一起,后者让一次性调用的对象不必起名字。

编译器的拷贝省略这一件事放在最后三节里讲。它不影响程序正确性,却会让所有靠打印计数得到的结论失准,所以这一篇的实测都带上编译选项,把优化开着与关掉的两种结果并排摆出来。

图 1 从初始化列表到匿名对象的一条主线

一、构造函数体与初始化列表

构造函数有两个执行阶段,写惯了函数体赋值的人容易把这件事忽略掉。成员在进入函数体之前就已经构造完毕,函数体里写的语句属于赋值;真正决定成员初值的地方是初始化列表(member initializer list),也就是构造函数名后面那个冒号引出来的部分。两者在语义上差别很大,对内置类型来说结果看起来一样,对引用、const 成员、以及没有默认构造函数的类类型成员来说,能写的位置只有一个。

下面这个例子把两个阶段都用打印标出来。Date 里放了一个 Time 类型的成员 _t,Time 的构造函数在函数体里打印一行,Date 的构造函数在函数体里打印一行,运行之后哪一行先出现在屏幕上,就说明哪个阶段先执行。同一份代码里还放了声明处缺省值与初始化顺序两个对照组,后面会分别用到。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/init_list.cpp
#include <iostream>
using namespace std;

class Time
{
public:
	Time(int hour = 0)
		: _hour(hour)
	{
		cout << "Time 的构造函数体:_hour = " << _hour << endl;
	}

	int GetHour() const
	{
		return _hour;
	}

private:
	int _hour;
};

class Date
{
public:
	Date(int year, int month, int day, int& ref)
		: _year(year)
		, _month(month)
		, _t(12)
		, _ref(ref)
		, _n(1)
	{
		cout << "Date 的构造函数体开始执行" << endl;
		_day = day;
	}

	void Print() const
	{
		cout << _year << "-" << _month << "-" << _day
			<< ",_t = " << _t.GetHour()
			<< ",_ref = " << _ref
			<< ",_n = " << _n << endl;
	}

private:
	int _year;
	int _month;
	int _day;
	Time _t;
	int& _ref;
	const int _n;
};

class Default
{
public:
	Default(int& ref)
		: _ref(ref)
	{}

	void Print() const
	{
		cout << _year << "-" << _month << "-" << _day
			<< ",_t = " << _t.GetHour()
			<< ",_n = " << _n << endl;
	}

private:
	int _year = 1;
	int _month = 1;
	int _day;
	Time _t = 1;
	int& _ref;
	const int _n = 1;
};

class Item
{
public:
	Item(const char* name)
		: _name(name)
	{
		cout << "初始化 " << _name << endl;
	}

private:
	const char* _name;
};

class Order
{
public:
	Order()
		: _b("第二行")
		, _a("第一行")
	{}

private:
	Item _a;
	Item _b;
};

int main()
{
	int year = 2025;
	Date d1(2024, 7, 14, year);
	d1.Print();

	cout << "---- 声明处给了缺省值的对象 ----" << endl;
	Default d2(year);
	d2.Print();

	cout << "---- 初始化顺序看声明,不看列表 ----" << endl;
	Order o;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
init_list.cpp: In constructor 'Order::Order()':
init_list.cpp:100:7: warning: 'Order::_b' will be initialized after [-Wreorder]
  Item _b;
       ^
init_list.cpp:99:7: warning:   'Item Order::_a' [-Wreorder]
  Item _a;
       ^
init_list.cpp:93:2: warning:   when initialized here [-Wreorder]
  Order()
  ^
$ ./init_list
Time 的构造函数体:_hour = 12
Date 的构造函数体开始执行
2024-7-14,_t = 12,_ref = 2025,_n = 1
---- 声明处给了缺省值的对象 ----
Time 的构造函数体:_hour = 1
1-1-4197597,_t = 1,_n = 1
---- 初始化顺序看声明,不看列表 ----
初始化 第一行
初始化 第二行

回显的第一行是 Time 的构造函数体,说明初始化的动作发生在函数体之前,_t(12) 这一条已经把 Time 的构造函数跑完了,Date 自己的函数体才轮到执行。顺序颠倒过来写不会有任何变化,初始化列表里各项的书写顺序不参与决定执行的先后。Date 的构造函数把 _day 放在函数体里赋值,这个成员因此没有走初始化列表,稍后会看到它的值有多不可靠。

图 2 一个对象构造时走的两步

初始化列表在写法上有几条必须遵守的规矩。它必须以冒号开始,各项之间用逗号分隔,每个成员后面跟一对括号,括号里放初值或者表达式。同一个成员在列表里只能出现一次,重复写会让编译器直接报错,报错信息里点名说这个成员被初始化了多次。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err9.cpp: In constructor 'A::A(int)':
err9.cpp:8:2: error: multiple initializations given for 'A::_a1'
  A(int a)
  ^

哪几类成员必须写在初始化列表里,这是最常考的一点。引用成员没有默认值可赋,const 成员一旦构造完成就不能再改,没有默认构造函数的类类型成员拿不到一个可用的初始状态,这三类成员如果不写进列表,编译器会在构造函数的位置停下。三份报错分别针对这三种情况,其中引用与 const 两份带 -fpermissive 标记,意思是加上这个选项就能降级成警告放过去,代价是把问题留到运行期。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err2.cpp: In constructor 'Date::Date(int)':
err2.cpp:8:2: error: uninitialized reference member 'Date::_ref' [-fpermissive]
  Date(int year)
  ^
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err3.cpp: In constructor 'Date::Date(int)':
err3.cpp:8:2: error: uninitialized member 'Date::_n' with 'const' type 'const int' [-fpermissive]
  Date(int year)
  ^
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err1.cpp: In constructor 'Date::Date(int)':
err1.cpp:20:15: error: no matching function for call to 'Time::Time()'
   : _year(year)
               ^
err1.cpp:20:15: note: candidates are:
err1.cpp:8:2: note: Time::Time(int)
  Time(int hour)
  ^
err1.cpp:8:2: note:   candidate expects 1 argument, 0 provided
err1.cpp:5:7: note: constexpr Time::Time(const Time&)
 class Time
       ^
err1.cpp:5:7: note:   candidate expects 1 argument, 0 provided
err1.cpp:5:7: note: constexpr Time::Time(Time&&)
err1.cpp:5:7: note:   candidate expects 1 argument, 0 provided

第一份报错针对引用成员 _ref,第二份针对 const 成员 _n,第三份针对 Time 类型成员 _t。第三份的信息最长,编译器把三个候选构造函数列了出来,Time::Time(int) 需要一个参数却拿到零个,编译器合成的拷贝构造与移动构造同样需要一个参数。看懂了这份输出,就明白为什么没有默认构造的类类型成员必须在列表里逐个喂初值。

C++11 起还可以在成员声明的位置直接给缺省值,写法与初始化列表不冲突,两者的关系是列表优先。一个成员既在声明处给了缺省值、又出现在初始化列表里,最终生效的是列表里的那一份;反过来,一个成员只有声明处的缺省值,那么所有没在列表里提到它的构造函数,都会用这个缺省值初始化它。测试机上的 g++ 4.8.5 对这项特性的态度值得记一笔,加上 -std=c++11 一切正常,换成 -std=c++98 只给出警告而不是错误,程序照样算出 1。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ./err10
1
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err10.cpp:14:11: warning: non-static data member initializers only available with -std=c++11 or -std=gnu++11 [enabled by default]
  int _a = 1;
           ^
$ ./err10_98
1

既然每个成员都要走初始化列表,那么没写进列表的成员会走到哪里去,就成了一个必须回答的问题。答案是它也会走初始化列表,用声明处的缺省值,或者用默认构造。内置类型没有默认构造,编译器也不会替它清零,于是读到什么值取决于这块内存上原来放着什么。实测里把 _day 从列表里拿掉之后,同一个程序在 -O0 下打印 4197597,在 -O2 下打印 4197142,两个数字都没有意义,编译器对这种情况一个字也不提醒,因为后面要不要给 _day 赋值它看不出来。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ./init_list_O2
Time 的构造函数体:_hour = 12
Date 的构造函数体开始执行
2024-7-14,_t = 12,_ref = 2025,_n = 1
---- 声明处给了缺省值的对象 ----
Time 的构造函数体:_hour = 1
1-1-4197142,_t = 1,_n = 1
---- 初始化顺序看声明,不看列表 ----
初始化 第一行
初始化 第二行

初始化顺序是这一节最容易被忽略的一条。成员按照它们在类里声明的先后被初始化,与初始化列表里写的先后没有关系,编译器对顺序不一致的情况会给一条 -Wreorder 警告。init_list.cpp 里既有这个警告也有一组直接观察顺序的打印,Order 类里 _a 声明在前、_b 声明在后,构造函数把 _b 写在列表前面,运行时先打印的仍然是 _a 那一行。

顺序写反的危害在内置类型上看得更清楚。下面这个 edge.cpp 里 _a2 声明在 _a1 之前,列表里先写 _a1(a) 再写 _a2(_a1),本意是让 _a2 拿到 _a1 的值,实际执行时 _a2 先被初始化,它读到的是 _a1 尚未初始化的内容。关掉优化时打印 32765,打开 -O2 时打印 0,两个值都是垃圾数据,区别只在于栈上原先留着什么。为了排除偶然性,同一个程序里用定位 new(placement new)把一块内存先写成 0x55 再在上面构造对象,取出的成员正好是 1431655765,也就是 0x55555555。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/edge.cpp
#include <iostream>
#include <cstring>
#include <new>
using namespace std;

class Reorder
{
public:
	Reorder(int a)
		: _a1(a)
		, _a2(_a1)
	{}

	void Print() const
	{
		cout << "_a1 = " << _a1 << ",_a2 = " << _a2 << endl;
	}

private:
	int _a2;
	int _a1;
};

class Uninit
{
public:
	Uninit()
	{}

	void Print() const
	{
		cout << "_a = " << _a << ",_b = " << _b << endl;
	}

private:
	int _a;
	int _b;
};

int main()
{
	Reorder r(1);
	r.Print();

	char* raw = new char[sizeof(Uninit)];
	memset(raw, 0x55, sizeof(Uninit));
	Uninit* p = new (raw) Uninit();
	p->Print();
	cout << "这块内存事先被写成了 0x55,0x55555555 = " << 0x55555555 << endl;
	p->~Uninit();
	delete[] raw;

	int arr[3] = { 1, 2, 3 };
	cout << "sizeof(arr) = " << sizeof(arr) << ",数组名不退化" << endl;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
edge.cpp: In constructor 'Reorder::Reorder(int)':
edge.cpp:22:6: warning: 'Reorder::_a1' will be initialized after [-Wreorder]
  int _a1;
      ^
edge.cpp:21:6: warning:   'int Reorder::_a2' [-Wreorder]
  int _a2;
      ^
edge.cpp:10:2: warning:   when initialized here [-Wreorder]
  Reorder(int a)
  ^
edge.cpp: In constructor 'Reorder::Reorder(int)':
edge.cpp:22:6: warning: 'Reorder::_a1' will be initialized after [-Wreorder]
  int _a1;
      ^
edge.cpp:21:6: warning:   'int Reorder::_a2' [-Wreorder]
  int _a2;
      ^
edge.cpp:10:2: warning:   when initialized here [-Wreorder]
  Reorder(int a)
  ^
edge.cpp: In function 'int main()':
edge.cpp:12:12: warning: 'r.Reorder::_a1' is used uninitialized in this function [-Wuninitialized]
   , _a2(_a1)
            ^
edge.cpp:43:10: note: 'r' was declared here
  Reorder r(1);
          ^
$ ./edge_O0
_a1 = 1,_a2 = 32765
_a = 1431655765,_b = 1431655765
这块内存事先被写成了 0x55,0x55555555 = 1431655765
sizeof(arr) = 12,数组名不退化

$ ./edge_O2
_a1 = 1,_a2 = 0
_a = 1431655765,_b = 1431655765
这块内存事先被写成了 0x55,0x55555555 = 1431655765
sizeof(arr) = 12,数组名不退化

图 3 成员按声明顺序初始化

二、类型转换

一个只带一个参数、并且没有加 explicit 的构造函数,在编译器眼里同时是一份转换规则。写 A aa1 = 1; 的时候,编译器先调用 A(int) 构造一个临时的 A 对象,再用这个临时对象去初始化 aa1。这一路走下来本该有一次构造加一次拷贝构造,实际运行时只打印了一次构造,原因在最后三节会讲清楚,编译器把中间那份临时对象省掉了。

构造函数的入口除了上面那种还能接受这几种写法。const A& aa2 = 3; 用一个 const 引用接住转换出来的临时对象,临时对象的生命周期被延长到引用的作用域结束。A aa3 = {2, 2}; 用花括号把两个参数喂给双参构造函数,这是 C++11 起才有的列表初始化(list initialization),在 -std=c++98 下编不过。类类型之间同样能互相转换,只要目标类型存在一个接受源类型的构造函数,B b = aa3; 就会调用 B(const A&)。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/convert.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A(int a = 0)
		: _a1(a)
		, _a2(a)
	{
		cout << "A(int) 构造,两个成员都是 " << a << endl;
	}

	A(int a1, int a2)
		: _a1(a1)
		, _a2(a2)
	{
		cout << "A(int, int) 构造," << a1 << " 与 " << a2 << endl;
	}

	int GetA1() const
	{
		return _a1;
	}

	int GetA2() const
	{
		return _a2;
	}

private:
	int _a1;
	int _a2;
};

class B
{
public:
	B(const A& a)
		: _b(a.GetA1() + a.GetA2())
	{
		cout << "B(const A&) 构造,A 的两个成员相加得到 " << _b << endl;
	}

private:
	int _b;
};

class C
{
public:
	C(int c = 0)
		: _c(c)
	{}

	operator int() const
	{
		cout << "operator int() 被调用一次" << endl;
		return _c;
	}

private:
	int _c;
};

class D
{
public:
	D(int d = 0)
		: _d(d)
	{}

	explicit operator int() const
	{
		cout << "explicit operator int() 被调用一次" << endl;
		return _d;
	}

private:
	int _d;
};

int main()
{
	cout << "---- 单参构造带来的隐式转换 ----" << endl;
	A aa1 = 1;
	const A& aa2 = 3;
	A aa3 = {2, 2};

	cout << "---- 类类型之间的隐式转换 ----" << endl;
	B b = aa3;
	const B& rb = aa3;

	cout << "---- 转换运算符被自动调用 ----" << endl;
	C c(7);
	int x = c;
	cout << "int x = c 之后 x = " << x << endl;
	cout << "c + 1 = " << c + 1 << endl;
	cout << "c > 1 的结果是 " << (c > 1) << endl;

	cout << "---- explicit 的转换运算符只能显式调用 ----" << endl;
	D d(9);
	int y = (int)d;
	cout << "int y = (int)d 之后 y = " << y << endl;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
convert.cpp: In function 'int main()':
convert.cpp:87:4: warning: variable 'aa1' set but not used [-Wunused-but-set-variable]
  A aa1 = 1;
    ^
convert.cpp:88:11: warning: unused variable 'aa2' [-Wunused-variable]
  const A& aa2 = 3;
           ^
convert.cpp:92:4: warning: variable 'b' set but not used [-Wunused-but-set-variable]
  B b = aa3;
    ^
convert.cpp:93:11: warning: unused variable 'rb' [-Wunused-variable]
  const B& rb = aa3;
           ^
$ ./convert
---- 单参构造带来的隐式转换 ----
A(int) 构造,两个成员都是 1
A(int) 构造,两个成员都是 3
A(int, int) 构造,2 与 2
---- 类类型之间的隐式转换 ----
B(const A&) 构造,A 的两个成员相加得到 4
B(const A&) 构造,A 的两个成员相加得到 4
---- 转换运算符被自动调用 ----
operator int() 被调用一次
int x = c 之后 x = 7
operator int() 被调用一次
c + 1 = 8
operator int() 被调用一次
c > 1 的结果是 1
---- explicit 的转换运算符只能显式调用 ----
explicit operator int() 被调用一次
int y = (int)d 之后 y = 9

编译阶段的两条警告与转换无关,属于变量定义了却没有使用,编译器也会提醒一句。回显里的转换线索集中在构造函数的打印上,四次转换各打印了一次构造,拷贝构造一次都没有出现。转换运算符的部分值得单独看:C 类里定义了 operator int() const,于是 int x = c、c + 1、c > 1 三处都在背后调用了它,回显里三行「operator int() 被调用一次」对应的是同一个函数。

转换的方向反过来说也成立,一个类可以同时作为转换的起点与转换的终点,这两种规则凑在一起时编译器的选择会变得微妙,本章不做展开。把隐式转换关掉的办法是给构造函数加 explicit,加上之后 A aa1 = 1; 这类写法会被拒绝,编译器给出的信息是没法把 int 转成 A。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err8.cpp: In function 'int main()':
err8.cpp:18:10: error: conversion from 'int' to non-scalar type 'A' requested
  A aa1 = 1;
          ^

转换运算符前面也能加 explicit。D 类里的 explicit operator int() const 只在显式写法下生效,实测中 int y = (int)d 能过,把括号去掉就编不过了。判断一个隐式转换会不会发生,看的就是这条规则:构造函数与转换运算符只要没被 explicit 拦住,编译器就会在类型不匹配的时候自己动手。

图 4 单参构造的隐式转换路径

三、static 成员变量

用 static 修饰的成员变量叫静态成员变量(static member variable)。它属于类而不属于某个对象,同一个类的所有对象共用这一份,存放在静态存储区,对象本身的内存里没有它的位置。写法上分成两截,类里面只写声明,定义与初始值放在类外面的源文件里,int A::_scount = 0; 这一行的作用域是类外,所以要带上类名限定。

下面这个 static_member.cpp 把静态变量当成一个存活计数器用,构造时加一,析构时减一,中间用花括号圈出一个作用域,观察计数在块内块外的变化。地址与 sizeof 的对照也放在同一个程序里,便于一次跑完。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/static_member.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A()
		: _a(0)
	{
		++_scount;
		cout << "构造,当前存活 " << _scount << " 个" << endl;
	}

	A(const A& aa)
		: _a(aa._a)
	{
		++_scount;
		cout << "拷贝构造,当前存活 " << _scount << " 个" << endl;
	}

	~A()
	{
		--_scount;
		cout << "析构,当前存活 " << _scount << " 个" << endl;
	}

	static int GetCount()
	{
		return _scount;
	}

	static const int* GetStaticAddr()
	{
		return &_scount;
	}

	static int SGetA(int a)
	{
		return a;
	}

	int GetA() const
	{
		return _a;
	}

private:
	int _a;
	static int _scount;
};

int A::_scount = 0;

void Func(A aa)
{
	cout << "传值传参,函数内的计数是 " << A::GetCount() << endl;
}

void FuncRef(const A& aa)
{
	cout << "const 引用传参,函数内的计数是 " << A::GetCount() << endl;
}

int main()
{
	cout << "程序刚开始,A::GetCount() = " << A::GetCount() << endl;
	A aa1;
	{
		A aa2;
		cout << "块内,A::GetCount() = " << A::GetCount() << endl;
	}
	cout << "块外,A::GetCount() = " << A::GetCount() << endl;

	cout << "用对象调用静态成员函数 " << aa1.GetCount() << endl;
	cout << "用类名调用静态成员函数 " << A::GetCount() << endl;

	cout << "sizeof(A) = " << sizeof(A) << ",静态成员不占对象的空间" << endl;
	cout << "对象 aa1 的地址 " << &aa1 << endl;
	cout << "静态成员 _scount 的地址 " << A::GetStaticAddr() << endl;
	cout << "sizeof 静态成员函数指针 " << sizeof(&A::SGetA)
		<< ",sizeof 非静态成员函数指针 " << sizeof(&A::GetA) << endl;

	Func(aa1);
	FuncRef(aa1);
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ./static_member
程序刚开始,A::GetCount() = 0
构造,当前存活 1 个
构造,当前存活 2 个
块内,A::GetCount() = 2
析构,当前存活 1 个
块外,A::GetCount() = 1
用对象调用静态成员函数 1
用类名调用静态成员函数 1
sizeof(A) = 4,静态成员不占对象的空间
对象 aa1 的地址 0x7ffc770b5960
静态成员 _scount 的地址 0x6021b4
sizeof 静态成员函数指针 8,sizeof 非静态成员函数指针 16
拷贝构造,当前存活 2 个
传值传参,函数内的计数是 2
析构,当前存活 1 个
const 引用传参,函数内的计数是 1
析构,当前存活 0 个

回显里的信息可以分成下面这三组来做对照。第一组是计数的变化,程序开始时是 0,进块前构造一个对象变成 1,块内构造第二个变成 2,出块时第二个对象析构回到 1,计数跟着对象的生死走。第二组是调用方式,aa1.GetCount() 与 A::GetCount() 得到同一个结果,静态成员函数不依赖对象就能调用。第三组是地址与大小,sizeof(A) 是 4,只算了那个 int 成员;对象 aa1 在栈上,地址是 0x7ffc770b5960;静态成员 _scount 的地址是 0x6021b4,落在数据段里,两者相差几个数量级。

静态成员的位置可以直接从符号表里读出来。用 nm 把调试符号还原成可读名字之后,_scount 显示为 B 类型,也就是未初始化数据段(.bss),而对象成员只存在于栈或者堆上,符号表里根本没有它们的名字。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ nm -C static_member | grep -i scount
0000000000400cdc t _GLOBAL__sub_I__ZN1A7_scountE
00000000006021b4 B A::_scount

类内声明处不能给静态成员初值,这一点与前面小节里的普通成员正相反。普通成员靠声明处缺省值得到初值,静态成员在声明处写初值会被编译器直接拦下来,理由是内联初始化对非 const 的静态成员不成立。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err4.cpp:12:23: error: ISO C++ forbids in-class initialization of non-const static member 'A::_scount'
  static int _scount = 0;
                       ^

反过来,类外定义那一行如果漏写,编译阶段一切正常,错误要到链接的时候才出现。这类报错的形态与编译期完全不同,它说的是某个符号没有定义,末尾还会多出一行 collect2 的失败信息。漏写定义这种情况在分成多个源文件的项目里出现得更多,因为声明在头文件里、定义本该写在某个源文件里,两边对不上时编译器各看各的都是对的。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
/tmp/ccLaWHQC.o: In function `A::A()':
err6.cpp:(.text._ZN1AC2Ev[_ZN1AC5Ev]+0xa): undefined reference to `A::_scount'
err6.cpp:(.text._ZN1AC2Ev[_ZN1AC5Ev]+0x13): undefined reference to `A::_scount'
/tmp/ccLaWHQC.o: In function `A::GetCount()':
err6.cpp:(.text._ZN1A8GetCountEv[_ZN1A8GetCountEv]+0x6): undefined reference to `A::_scount'
collect2: error: ld returned 1 exit status

静态存储期还有一个常考的题目,就是对象的构造与析构顺序。下面这个程序里有四个类,C 类型的全局对象 c 在 main 之前构造,A 与 B 的局部对象在 main 里构造,D 类型的静态局部对象 d 只在第一次执行到声明处时构造一次,析构阶段又落在后面。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/dtor_order.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A() { cout << "A 构造" << endl; }
	~A() { cout << "A 析构" << endl; }
};

class B
{
public:
	B() { cout << "B 构造" << endl; }
	~B() { cout << "B 析构" << endl; }
};

class C
{
public:
	C() { cout << "C 构造" << endl; }
	~C() { cout << "C 析构" << endl; }
};

class D
{
public:
	D() { cout << "D 构造" << endl; }
	~D() { cout << "D 析构" << endl; }
};

C c;

int main()
{
	A a;
	B b;
	static D d;
	cout << "main 的 return 之前" << endl;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ./dtor_order
C 构造
A 构造
B 构造
D 构造
main 的 return 之前
B 析构
A 析构
D 析构
C 析构

构造顺序是 c 先于所有局部对象,局部对象之间按声明的先后,静态局部对象 d 与局部对象同处一个作用域,也在这一批里。析构顺序要分两段看,a 与 b 是自动存储期,它们在 main 返回处按构造的逆序析构;c 与 d 的析构都排在程序退出阶段,d 的构造晚于 c,因此 d 先析构、c 最后析构。全部输出里最后一行是 C 析构,对应的正是最先构造的那个全局对象。

图 5 静态成员与对象成员的内存位置

四、static 成员函数

用 static 修饰的成员函数叫静态成员函数(static member function)。它的定义与普通成员函数没有区别,差别在于编译器不会给它传 this 指针,因此它没有办法知道自己是哪个对象调用的。这一条限制直接决定了它能碰什么:静态成员没有 this 指针也能访问,它属于类;非静态成员必须通过某个对象才能访问,静态成员函数里没有这个对象,写出来就会报错。

调用静态成员函数有两种写法,一是用对象调用,aa1.GetCount(),二是用类名加作用域限定调用,A::GetCount(),两种写法在实测里给出同一个结果。第二种写法体现了静态成员突破类域的效果,只要拿到了类名,不需要任何对象就能访问到这个函数。突破类域与绕过访问限定是两件事,静态成员如果声明在 private 区域,类外依旧碰不到它,要靠公开接口或者友元才能进去。

在静态成员函数里直接写非静态成员的后果在下面这段代码里可以看到。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err5.cpp: In static member function 'static int A::GetA()':
err5.cpp:14:11: error: invalid use of member 'A::_a' in static member function
  int _a = 1;
           ^
err5.cpp:10:10: error: from this location
   return _a;
          ^
err5.cpp:11:2: warning: control reaches end of non-void function [-Wreturn-type]
  }
  ^

报错的行号指向成员变量的使用处,编译器给出的理由是静态成员函数里用到了无效的成员,也就是它找不到 this。绕过这条限制的办法有两种:把需要访问的对象作为参数传进来,写成 static int SGetA(A aa) 这种形式,通过参数里的对象去访问成员;或者干脆改用非静态成员函数,让编译器把 this 传进去。

静态成员函数与普通成员函数在内存里的形态也不同。前面 static_member.cpp 的回显里有一组 sizeof 对照,静态成员函数的指针占 8 字节,非静态成员函数的指针占 16 字节。原因在于非静态成员函数需要额外的信息来定位对象,指针里不止存了函数入口,静态成员函数与普通函数没有区别,一个 8 字节的地址就够了。

五、静态成员的典型用途

静态成员最直接的用处是让整个类共享一份状态。static_member.cpp 里的 _scount 就是一个例子,构造与析构各自改一次这个变量,任何时刻读出来的都是当前存活的对象个数。这类计数不需要作用在某个对象上,用类名加静态函数就能读到,写起来比在外部维护一个全局变量更有约束,因为计数只在这个类的成员函数里改得到。

把静态成员当计数器用还有第二种形态,给每个对象发一个不同的编号。做法是在构造函数里把静态变量自增之后赋给自己的非静态成员,静态变量记住的是已经发出去多少个号,非静态成员记住的是这个对象自己的号。两个变量分工明确,前者在静态区里只有一份,后者每个对象各有一份。

静态局部对象的作用是让构造在整个程序里只发生一次。dtor_order.cpp 里的 static D d; 写在 main 的函数体里,程序执行到这一行时构造,之后再执行到这里也不会构造第二份,因此它常被用来做需要延迟初始化又只能有一份的东西。这份唯一性有代价,静态局部对象的析构排在程序退出阶段,它的生命周期与函数调用无关,析构顺序又不容易在代码里一眼看清,所以放在函数里管理资源时要多留意一层。

静态成员的代价与它的便利来自同一个地方,它是一份全局状态,而全局状态在程序里没有自然的作用域边界。任何能访问到它的人都能改它,因此把可变状态放进静态成员,等于把这个类变成了隐式的共享点。除此之外,静态成员的初始化发生在程序启动阶段,多个翻译单元里定义的静态对象谁先谁后并没有明确保证,跨文件的静态对象互相依赖时要格外小心。

静态成员与静态成员函数这两件事到这里都讲完了。再往下走一层,能不能让类外面的普通函数或者另一个类也访问到私有成员,这是友元要处理的事情。

六、友元函数与友元类

访问限定符把私有成员圈在类里面,友元(friend)是这套规则上开的一个口子。声明的方式是在函数或者类的声明前面加 friend 关键字,并且把这份声明写在某个类的内部。被声明成友元的函数可以访问这个类的私有与保护成员,被声明成友元的类,它的所有成员函数都可以访问对方的私有与保护成员。

友元函数不是类的成员函数,这一点决定了很多细节。它没有 this 指针,也没有隐含的对象,因此参数里必须把要访问的对象显式传进来。它可以声明在类里面的任何位置,放在 public 区域与放在 private 区域没有任何区别,编译器看的是 friend 这个关键字而不是访问限定符。一个函数可以同时是多个类的友元,只要在每个类里都写一遍同事的声明。

下面这一段程序里面一共安排出了三个类,分别是 A、B 与 C。A 把普通函数 Func 声明成友元,同时把类 B 声明成友元;B 也把 Func 声明成友元;C 什么都没写,只能走 A 的公开接口。Func 的参数同时用到 A 与 B 两个类型,因此在 A 的定义之前需要一行前置声明 class B;,否则 Func 的声明里那个 B 类型编译器认不出来。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/friend_demo.cpp
#include <iostream>
using namespace std;

class B;

class A
{
	friend void Func(const A& aa, const B& bb);
	friend class B;

public:
	A(int a1 = 0, int a2 = 0)
		: _a1(a1)
		, _a2(a2)
	{}

	int GetSum() const
	{
		return _a1 + _a2;
	}

private:
	int _a1;
	int _a2;
};

class B
{
	friend void Func(const A& aa, const B& bb);

public:
	B(int b1 = 0, int b2 = 0)
		: _b1(b1)
		, _b2(b2)
	{}

	void Func1(const A& aa)
	{
		cout << "B::Func1 读 A 的私有成员 " << aa._a1
			<< ",读自己的 " << _b1 << endl;
	}

	void Func2(const A& aa)
	{
		cout << "B::Func2 读 A 的私有成员 " << aa._a2
			<< ",读自己的 " << _b2 << endl;
	}

private:
	int _b1;
	int _b2;
};

void Func(const A& aa, const B& bb)
{
	cout << "Func 读 A 的私有成员 " << aa._a1 << " 与 " << aa._a2
		<< ",读 B 的私有成员 " << bb._b1 << " 与 " << bb._b2 << endl;
}

class C
{
public:
	void Func(const A& aa)
	{
		cout << "C::Func 不是友元,只能走公开接口 " << aa.GetSum() << endl;
	}
};

int main()
{
	A aa(1, 2);
	B bb(3, 4);
	Func(aa, bb);
	bb.Func1(aa);
	bb.Func2(aa);
	C cc;
	cc.Func(aa);
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo

$ ./friend_demo
Func 读 A 的私有成员 1 与 2,读 B 的私有成员 3 与 4
B::Func1 读 A 的私有成员 1,读自己的 3
B::Func2 读 A 的私有成员 2,读自己的 4
C::Func 不是友元,只能走公开接口 3

四行输出正好覆盖了四种互不相同的访问身份。Func 是 A 与 B 双方的友元,所以它能一次读完两个类的私有成员,输出里出现了 1、2、3、4 这四个内部的数字。B::Func1 与 B::Func2 是 B 的成员函数,B 被 A 声明为友元类,因此它们能读到 A 的私有成员,同时读自己类的成员。C::Func 与 A 之间没有任何友元关系,它读不到 A 的私有成员,只能调用公开的 GetSum,拿到的 3 是两个私有成员相加的结果。

把 Func 的 friend 声明去掉之后,同一个函数立刻编不过。报错的位置在函数体里访问私有成员的那几行,编译器给出的信息是成员是私有的,紧接着一行补充说明报错发生在当前的上下文里,也就是函数 Func 内部。这类报错里编译器通常会给出两个候选解释,一是指出访问权限不足,二是指出成员名字本身不存在,看报错时要确认到底是哪一种。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err7.cpp: In function 'void func(const A&)':
err7.cpp:13:6: error: 'int A::_a1' is private
  int _a1;
      ^
err7.cpp:18:13: error: within this context
  cout << aa._a1 << endl;
             ^

友元关系在方向上有两条限制,一条关于对称性,一条关于传递性。第一是不对称,A 把 B 声明为友元,只意味着 B 能访问 A 的私有成员,A 并不能因此访问 B 的私有成员,访问的通行证是单向发的。第二是不能传递,A 把 B 当友元、B 把 C 当友元,C 与 A 之间没有任何关系,C 读不到 A 的私有成员。这两条都来自同一个设计意图,友元是一种明确的、点对点的授权,而不是可以在类之间流传的权限。

图 6 友元关系的单向性

友元带来的便利与它同时带来的代价一样明显。它把两个本来只通过公开接口打交道的类绑在一起,任何一个类改动了内部的成员,友元那边都可能要跟着改,耦合度因此上升,封装也被开了一个口子。实际用的时候一般是给运算符重载或者两个联系极紧的类开友元,能不写就不写。

七、内部类

定义在另一个类里面的类叫内部类(nested class)。它是一个完全独立的类,与定义在全局的类相比只多了一条约束,它受外部类的类域与访问限定符限制,类外面想用它就得带上外部类的名字,写成 A::B 这种形式。内部类与外部类之间没有对象上的包含关系,外部类的对象里不会多出内部类的成员。

下面这个程序里 A 有一个公开的内部类 B 和一个藏在 private 位置的内部类 Hidden,B 的成员函数要读 A 的私有成员,因此参数里必须传一个 A 的对象进来。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/inner_demo.cpp
#include <iostream>
using namespace std;

class A
{
public:
	class B
	{
	public:
		B(int b = 0)
			: _b(b)
		{
			cout << "内部类 B 构造了,_b = " << _b << endl;
		}

		void Func(const A& a)
		{
			cout << "内部类读外部类的私有静态成员 _k = " << _k << endl;
			cout << "内部类读外部类对象的私有成员 a._h = " << a._h << endl;
			cout << "内部类读自己的成员 _b = " << _b << endl;
		}

	private:
		int _b;
	};

	A(int h = 1)
		: _h(h)
	{}

	B MakeB(int b)
	{
		return B(b);
	}

private:
	class Hidden
	{
	public:
		Hidden()
		{
			cout << "藏在私有位置的内部类构造了" << endl;
		}
	};

	static int _k;
	int _h;
};

int A::_k = 1;

int main()
{
	cout << "sizeof(A) = " << sizeof(A) << ",外部类对象里只有自己的成员" << endl;
	cout << "sizeof(A::B) = " << sizeof(A::B) << ",内部类是另一个独立的类" << endl;
	A aa(1);
	A::B bb = aa.MakeB(2);
	bb.Func(aa);
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo

$ ./inner_demo
sizeof(A) = 4,外部类对象里只有自己的成员
sizeof(A::B) = 4,内部类是另一个独立的类
内部类 B 构造了,_b = 2
内部类读外部类的私有静态成员 _k = 1
内部类读外部类对象的私有成员 a._h = 1
内部类读自己的成员 _b = 2

两行 sizeof 说明了第一个要点。A 的对象占 4 字节,里面只有一个 int 成员 _h,内部类 B 虽然写在 A 里面,它并没有成为 A 的成员,因此不占 A 的空间。B 自己单独算一次大小,也是 4 字节,它有自己的成员 _b。构造 B 的对象要写成 A::B bb = aa.MakeB(2),对象本身与 A 的对象 aa 是两个独立的对象。

接下来三行说明内部类默认是外部类的友元。B::Func 直接读到了 A 的私有静态成员 _k,也读到了外部对象 a 的私有成员 _h,这两处访问在类外面都会被拒绝。需要注意参数 a 的作用,内部类没有外部类的 this 指针,它只是被授予了访问权限,具体读哪个外部对象的成员,仍然要从参数里传进来。

内部类放在 private 或者 protected 位置时,它就成了外部类私有的东西,类外面既不能构造它的对象,也不能用它的名字。这种写法常出现在两个类联系极紧的场景里,一个类存在的意义就是给另一个类提供服务,把服务提供者藏在调用者内部,外面就碰不到它了。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err11.cpp: In function 'int main()':
err11.cpp:8:8: error: 'class A::Hidden' is private
  class Hidden
        ^
err11.cpp:26:5: error: within this context
  A::Hidden h;
     ^

内部类的这个特性可以解一道不能用循环的题。题目要求算 1 加 2 一直加到 n,同时不许用乘除法,不许用 for、while、if、else、switch、case 这些关键字,条件判断语句也不能用。思路是把累加放进构造函数里,外部类需要几个数就构造几个对象,构造的过程本身就是循环。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/jz64.cpp
#include <iostream>
using namespace std;

class Solution
{
private:
	class Sum
	{
	public:
		Sum()
		{
			_ret += _i;
			++_i;
		}
	};

public:
	int Sum_Solution(int n)
	{
		Sum arr[n];
		return _ret;
	}

private:
	static int _i;
	static int _ret;
};

int Solution::_i = 1;
int Solution::_ret = 0;

int main()
{
	Solution st;
	cout << "Sum_Solution(10) = " << st.Sum_Solution(10) << endl;
	Solution st2;
	cout << "Sum_Solution(100) = " << st2.Sum_Solution(100) << endl;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
jz64.cpp: In member function 'int Solution::Sum_Solution(int)':
jz64.cpp:21:7: warning: unused variable 'arr' [-Wunused-variable]
   Sum arr[n];
       ^
$ ./jz64
Sum_Solution(10) = 55
Sum_Solution(100) = 6105

静态成员 _i 是当前要加进去的数,_ret 是累加的结果。每构造一个 Sum 对象,_ret 加上 _i,再把 _i 加一。Sum_Solution 里的 Sum arr[n]; 一次构造出 n 个对象,构造函数被调用 n 次,_ret 里装的就是从 1 加到 n 的结果。Sum 被放在 Solution 的 private 区域,外面拿不到这个类,累加的过程只能由 Solution 自己的成员函数触发。

第二次调用打印的是 6105 而不是 5050,这个结果值得多想一步。静态成员只在第一次使用时初始化一次,第一次调用结束后 _ret 停在 55,_i 停在 11,第二次调用并没有把它们复位,于是从 11 加到 110 得到 6050,再加上残留的 55 正好是 6105。静态成员属于类而不属于某个对象,Solution 的两个对象 st 与 st2 共用同一份 _i 与 _ret,这正是前面静态成员一节的结论在具体题目上的体现。要让它每次都从 1 开始,需要在进入累加之前把两个静态成员复位。

Sum arr[n]; 这行数组的长度是函数参数,编译期并不确定,严格按 C++ 标准这是不允许的。编译时加上 -Wall 只会提示 arr 定义了没有使用,加上 -pedantic 才能看到标准层面的警告,GCC 把它当作一项扩展来支持。同样的功能用 Sum* p = new Sum[n]; 加 delete[] p; 也可以实现,这条路径在标准里是合法的。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
jz64.cpp: In member function 'int Solution::Sum_Solution(int)':
jz64.cpp:21:7: warning: unused variable 'arr' [-Wunused-variable]
   Sum arr[n];
       ^
$ ./jz64
Sum_Solution(10) = 55
Sum_Solution(100) = 6105

jz64.cpp: In member function 'int Solution::Sum_Solution(int)':
jz64.cpp:21:12: warning: ISO C++ forbids variable length array 'arr' [-Wvla]
   Sum arr[n];
            ^
jz64.cpp:21:7: warning: unused variable 'arr' [-Wunused-variable]
   Sum arr[n];
       ^
(上面是加 -pedantic 之后的输出)

图 7 内部类与外部类的关系

八、匿名对象

只写类名加一对括号、不给这个对象取名字,这样造出来的对象叫匿名对象。A(); 构造一个默认对象,A(1); 构造一个带参数的对象,两种写法都没有变量名。没有名字意味着后面的语句里没有办法再提到它,因此它的生命周期只到这一行结束,同一行里用完立刻析构。

下面这个程序把有名对象与匿名对象的析构时机放在一起对照,末尾还有一处把临时对象接到 const 引用上的写法。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/anon.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A(int a) 构造,_a = " << _a << endl;
	}

	~A()
	{
		cout << "~A() 析构,_a = " << _a << endl;
	}

	int GetA() const
	{
		return _a;
	}

private:
	int _a;
};

class Solution
{
public:
	int Sum_Solution(int n)
	{
		cout << "Sum_Solution 收到 " << n << endl;
		return n;
	}
};

A MakeA()
{
	return A(7);
}

int main()
{
	cout << "有名对象" << endl;
	A aa1;
	cout << "匿名对象 A()" << endl;
	A();
	cout << "匿名对象 A(1)" << endl;
	A(1);
	cout << "有名对象 A aa2(2)" << endl;
	A aa2(2);
	cout << "用匿名对象调一次成员函数" << endl;
	cout << Solution().Sum_Solution(10) << endl;
	cout << "把匿名对象的返回值接到 const 引用上" << endl;
	const A& ra = MakeA();
	cout << "ra.GetA() = " << ra.GetA() << endl;
	cout << "这一行之后 ra 还在用" << endl;
	cout << "main 的最后一行" << endl;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo

$ ./anon
有名对象
A(int a) 构造,_a = 0
匿名对象 A()
A(int a) 构造,_a = 0
~A() 析构,_a = 0
匿名对象 A(1)
A(int a) 构造,_a = 1
~A() 析构,_a = 1
有名对象 A aa2(2)
A(int a) 构造,_a = 2
用匿名对象调一次成员函数
Sum_Solution 收到 10
10
把匿名对象的返回值接到 const 引用上
A(int a) 构造,_a = 7
ra.GetA() = 7
这一行之后 ra 还在用
main 的最后一行
~A() 析构,_a = 7
~A() 析构,_a = 2
~A() 析构,_a = 0

回显里的信息可以分成下面这两组来对照。第一组是构造与析构的配对,A(); 这一行之后紧接着就是一行析构,A(1); 同理,而 A aa1; 与 A aa2(2); 的析构都排在 main 的最后一行之后。第二组是三行析构的顺序,_a 为 2 的那个对象先析构,接着是接到引用上的 _a 为 7 的对象,最后是 _a 为 0 的 aa1,析构顺序与构造顺序正好相反。

Solution().Sum_Solution(10) 这行把这套规则用到了实处,Solution 的匿名对象只用来调一次成员函数,调完即析构,函数体里的那行打印也在同一行完成。标准库里的仿函数经常这么写,排序时传入的比较器往往只需要一个临时对象,写 sort(a, a + 8, greater<int>()) 就够了,greater() 就是一个匿名对象。

把匿名对象接到 const 引用上会改变它的生命周期。const A& ra = MakeA(); 里 MakeA 返回的是一个临时对象,绑定到 const 引用之后,这个临时对象的生命周期被延长到引用的作用域结束,因此后面几行通过 ra 读它仍然有效,回显里 ra.GetA() 拿到了 7。延长只对绑定在这一处的引用成立,换成非 const 引用就绑不上临时对象了。

图 8 匿名对象与临时对象的生命周期

九、拷贝时的编译器优化

前面几节里出现过好几次「本该两次构造,实际只打印了一次」的现象。A aa1 = 1; 用转换构造函数造一个临时对象,再用它初始化 aa1,按理说有一次构造加一次拷贝构造;按值传参的函数调用,按理说实参到形参要走一次拷贝构造。实测里这些拷贝都没有出现,原因在于标准给编译器开了一道口子,允许它在特定场合直接把对象构造到最终的位置上,跳过中间的临时对象与拷贝构造。这道口子叫拷贝省略(copy elision)。

下面这个程序用一个计数类统计八种写法里的构造、拷贝、赋值、析构次数。Count 的四个静态计数在每组用例前复位一次,Report 打印这一组的结果。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/elide.cpp
#include <iostream>
using namespace std;

class Count
{
public:
	Count(int a = 0)
		: _a(a)
	{
		++ctor;
	}

	Count(const Count& c)
		: _a(c._a)
	{
		++copy;
	}

	Count& operator=(const Count& c)
	{
		_a = c._a;
		++assign;
		return *this;
	}

	~Count()
	{
		++dtor;
	}

	static void Reset()
	{
		ctor = copy = assign = dtor = 0;
	}

	static void Report(const char* name)
	{
		cout << name << " 构造 " << ctor << " 拷贝 " << copy
			<< " 赋值 " << assign << " 析构 " << dtor << endl;
	}

private:
	int _a;
	static int ctor;
	static int copy;
	static int assign;
	static int dtor;
};

int Count::ctor = 0;
int Count::copy = 0;
int Count::assign = 0;
int Count::dtor = 0;

void ByValue(Count c)
{
}

void ByConstRef(const Count& c)
{
}

Count MakeAnon()
{
	return Count(1);
}

int main()
{
	Count::Reset();
	{
		Count n(1);
		ByValue(n);
		Count::Report("有名对象传值传参  ");
	}

	Count::Reset();
	{
		ByValue(1);
		Count::Report("隐式转换传值传参  ");
	}

	Count::Reset();
	{
		ByValue(Count(1));
		Count::Report("匿名对象传值传参  ");
	}

	Count::Reset();
	{
		Count n(1);
		ByConstRef(n);
		Count::Report("const 引用传参    ");
	}

	Count::Reset();
	{
		ByConstRef(Count(1));
		Count::Report("const 引用传匿名  ");
	}

	Count::Reset();
	{
		MakeAnon();
		Count::Report("返回值丢掉        ");
	}

	Count::Reset();
	{
		Count r = MakeAnon();
		Count::Report("返回值初始化新对象");
	}

	Count::Reset();
	{
		Count r;
		r = MakeAnon();
		Count::Report("返回值赋给已有对象");
	}
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo

$ ./elide
有名对象传值传参   构造 1 拷贝 1 赋值 0 析构 1
隐式转换传值传参   构造 1 拷贝 0 赋值 0 析构 1
匿名对象传值传参   构造 1 拷贝 0 赋值 0 析构 1
const 引用传参     构造 1 拷贝 0 赋值 0 析构 0
const 引用传匿名   构造 1 拷贝 0 赋值 0 析构 1
返回值丢掉         构造 1 拷贝 0 赋值 0 析构 1
返回值初始化新对象 构造 1 拷贝 0 赋值 0 析构 0
返回值赋给已有对象 构造 2 拷贝 0 赋值 1 析构 1

八行输出里面只有两行的拷贝次数不是零。有名对象传值传参那一组拷贝了 1 次,这是实参已经有名字、编译器没有临时对象可以省,只能调用一次拷贝构造;返回值赋给已有对象那一组是 2 次构造加 1 次赋值,因为赋值运算符没法省略,赋值必然发生一次。剩下的六组拷贝数都是零,包括隐式转换传值、匿名对象传值、返回值丢掉、返回值初始化新对象这几种写法,临时对象被直接构造到了目标位置上。

把默认的 -O0、打开优化的 -O2、以及关掉省略的 -O0 -fno-elide-constructors 三档放在一起跑,差别就清楚了。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ./elide_O0
有名对象传值传参   构造 1 拷贝 1 赋值 0 析构 1
隐式转换传值传参   构造 1 拷贝 0 赋值 0 析构 1
匿名对象传值传参   构造 1 拷贝 0 赋值 0 析构 1
const 引用传参     构造 1 拷贝 0 赋值 0 析构 0
const 引用传匿名   构造 1 拷贝 0 赋值 0 析构 1
返回值丢掉         构造 1 拷贝 0 赋值 0 析构 1
返回值初始化新对象 构造 1 拷贝 0 赋值 0 析构 0
返回值赋给已有对象 构造 2 拷贝 0 赋值 1 析构 1

$ ./elide_O2
有名对象传值传参   构造 1 拷贝 1 赋值 0 析构 1
隐式转换传值传参   构造 1 拷贝 0 赋值 0 析构 1
匿名对象传值传参   构造 1 拷贝 0 赋值 0 析构 1
const 引用传参     构造 1 拷贝 0 赋值 0 析构 0
const 引用传匿名   构造 1 拷贝 0 赋值 0 析构 1
返回值丢掉         构造 1 拷贝 0 赋值 0 析构 1
返回值初始化新对象 构造 1 拷贝 0 赋值 0 析构 0
返回值赋给已有对象 构造 2 拷贝 0 赋值 1 析构 1

$ ./elide_no
有名对象传值传参   构造 1 拷贝 1 赋值 0 析构 1
隐式转换传值传参   构造 1 拷贝 1 赋值 0 析构 2
匿名对象传值传参   构造 1 拷贝 1 赋值 0 析构 2
const 引用传参     构造 1 拷贝 0 赋值 0 析构 0
const 引用传匿名   构造 1 拷贝 0 赋值 0 析构 1
返回值丢掉         构造 1 拷贝 1 赋值 0 析构 2
返回值初始化新对象 构造 1 拷贝 2 赋值 0 析构 2
返回值赋给已有对象 构造 2 拷贝 1 赋值 1 析构 2

前面两档编译选项下跑出来的输出逐字相同。这一点值得记下来,拷贝省略与开不开优化没有关系,-O0 下编译器照样做这件事,因此不能靠调低优化挡位来保留拷贝构造的调用。第三档加上 -fno-elide-constructors 之后,隐式转换传值、匿名对象传值、返回值丢掉这三组的拷贝数由 0 变成 1,返回值初始化新对象那一组由 0 变成 2,返回值赋给已有对象那一组也由 0 变成 1,对应的析构次数据此增加。

标准允许省略拷贝的场合可以归成下面三类。用一个临时对象初始化同类型的另一个对象,编译器可以直接在这块内存上构造最终对象;函数按值返回一个临时对象,返回值可以直接构造在调用方的接收位置;按值传参而实参本身是临时对象,形参可以直接在实参的位置上构造。这三类写法的共同点是最终对象的位置在构造开始之前就已经确定,中间那次搬运纯属多余。

图 9 拷贝省略的三类场合

十、优化在标准里的地位

这条例外不是编译器的自作主张,它写在语言标准里面,名称同样是拷贝省略。标准给出的表述是编译器被允许省略拷贝构造与析构函数的调用,也就是说省略与否由实现决定,程序不能假设这件事一定发生,也不能假设它一定不发生。同一份代码在不同编译器、同一个编译器的不同版本上,打印出来的构造与析构次数都可能不同。

容易被忽略的一点是,省略的是调用而不是函数本身。拷贝构造函数在那些场合里仍然必须是可用的,如果它被声明成私有或者被删除,程序会直接编译失败,尽管编译器本来打算跳过这次调用。也就是说,写出一个拷贝构造函数并把它放到 private 区域,并不能因为「这次拷贝会被省略」而绕开编译器的检查。

在这条规则的两个档位上,GCC 给出的开关并不在优化选项里。-fno-elide-constructors 属于 C++ 方言选项,也就是说编译器把「省不省拷贝」当作语言层面的实现选择来管理,而不是当作优化等级的一部分,这与前面 -O0 与 -O2 输出一致的现象互相印证。

C++17 之前,这些场合里省略是允许的;到了 C++17,其中一部分场合改成了必须省略,也就是无论编译器怎么想都得省掉这次拷贝。本机上的 g++ 是 4.8.5,只认得 -std=c++98 与 -std=c++11 两个档位,更高的标准连选项都不认,因此这一条在本机上没有办法给出实测对照,只能作为标准演变的方向记下来。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
g++: error: unrecognized command line option '-std=c++14'
g++: error: unrecognized command line option '-std=c++17'

十一、优化会让计数实验失效

计数与打印是两种观察手段,它们在拷贝省略面前都会失真。为了避免只相信程序自己的打印,这一节换一种方式,用调试器在构造函数上下断点,统计构造被调用的次数并打印调用栈。下面的程序里 Make 按值返回一个临时对象,Call 按值接收一个对象,两处都会牵涉到拷贝构造。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/stackdepth.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A(int a)
		: _a(a)
	{
		cout << "A(int) 构造" << endl;
	}

	A(const A& aa)
		: _a(aa._a)
	{
		cout << "A(const A&) 拷贝构造" << endl;
	}

private:
	int _a;
};

A Make()
{
	return A(1);
}

void Call(A aa)
{
	cout << "Call 体内" << endl;
}

int main()
{
	A a1 = Make();
	Call(a1);
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo

$ ./stackdepth
A(int) 构造
A(const A&) 拷贝构造
Call 体内

在 A::A(int) 与 A::A(const A&) 两个位置各下一个断点,每个断点命中时打印一次调用栈,两档的输出差别很直接。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ gdb -batch -x gdb.cmd ./stackdepth
Breakpoint 1 at 0x4008c5: file stackdepth.cpp, line 9.
Breakpoint 2 at 0x4008fc: file stackdepth.cpp, line 15.
=== 命中 A::A(int) ===
#0  A::A (this=0x7fffffffe490, a=1) at stackdepth.cpp:9
#1  0x00000000004007fa in Make () at stackdepth.cpp:26
#2  0x000000000040083e in main () at stackdepth.cpp:36
A(int) 构造
=== 命中 A::A(A const&) ===
#0  A::A (this=0x7fffffffe4a0, aa=...) at stackdepth.cpp:15
#1  0x0000000000400851 in main () at stackdepth.cpp:37
A(const A&) 拷贝构造
Call 体内
[Inferior 1 (process 17006) exited normally]
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ gdb -batch -x gdb.cmd ./stackdepth_noelide
Breakpoint 1 at 0x4008eb: file stackdepth.cpp, line 9.
Breakpoint 2 at 0x400922: file stackdepth.cpp, line 15.
=== 命中 A::A(int) ===
#0  A::A (this=0x7fffffffe460, a=1) at stackdepth.cpp:9
#1  0x00000000004007fa in Make () at stackdepth.cpp:26
#2  0x0000000000400851 in main () at stackdepth.cpp:36
A(int) 构造
=== 命中 A::A(A const&) ===
#0  A::A (this=0x7fffffffe490, aa=...) at stackdepth.cpp:15
#1  0x000000000040080d in Make () at stackdepth.cpp:26
#2  0x0000000000400851 in main () at stackdepth.cpp:36
A(const A&) 拷贝构造
=== 命中 A::A(A const&) ===
#0  A::A (this=0x7fffffffe480, aa=...) at stackdepth.cpp:15
#1  0x0000000000400864 in main () at stackdepth.cpp:36
A(const A&) 拷贝构造
=== 命中 A::A(A const&) ===
#0  A::A (this=0x7fffffffe4a0, aa=...) at stackdepth.cpp:15
#1  0x0000000000400877 in main () at stackdepth.cpp:37
A(const A&) 拷贝构造
Call 体内
[Inferior 1 (process 17018) exited normally]

默认档下拷贝构造函数的断点一共命中两次。A(int) 那一次的栈帧里能看到它由 Make 调用,返回值直接落在 main 的接收位置上,拷贝构造函数只被 main 调用了一次,对应的是 Call(a1) 这次按值传参。关掉省略之后一共命中四次,多出来的两次拷贝构造一次挂在 Make 的栈帧上,另一次挂在 main 里初始化 a1 的那一行上,正好是编译器本来打算省掉的两处搬运。

断点计数与打印计数给出的是同一个结论:观察对象构造的次数时,编译器可以合法地把次数改掉。构造与析构里有副作用时,这一点最容易被忽略,日志少了一行、计数小了一个、文件句柄少关一次,都可能是因为这次调用被省略了。要观察真实的调用次数,编译时加上 -fno-elide-constructors;要判断一段代码在不同编译器上的行为,前提是这些副作用不参与程序的正确性。

图 10 计数实验为什么会被优化破坏

实例代码

工程结构

所有源文件都放在 /home/cocatrice/cpp05/demo 目录里,共 24 个 .cpp。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
/home/cocatrice/cpp05/demo
  init_list.cpp       初始化列表、声明处缺省值与初始化顺序
  edge.cpp            顺序写反、定位 new 与未初始化的内置类型
  convert.cpp         单参构造的隐式转换与转换运算符
  static_member.cpp   静态成员的计数、地址与 sizeof 对照
  dtor_order.cpp      全局对象、局部对象与静态局部对象的构造析构顺序
  friend_demo.cpp     友元函数与友元类
  inner_demo.cpp      内部类、私有内部类与类域
  jz64.cpp            用内部类与静态成员算 1 加到 n
  anon.cpp            匿名对象与临时对象的生命周期
  elide.cpp           拷贝省略的八组用例与三档对照
  stackdepth.cpp      构造函数调用的调用栈观察
  composite.cpp       把本章要点串起来的综合程序
  err1.cpp 到 err12.cpp   十二个故意编不过的文件

目录里实际的文件清单与上面这份一致,用 ls 数一遍是 24 个源文件。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ls -1 *.cpp | sort
anon.cpp
composite.cpp
convert.cpp
dtor_order.cpp
edge.cpp
elide.cpp
err10.cpp
err11.cpp
err12.cpp
err1.cpp
err2.cpp
err3.cpp
err4.cpp
err5.cpp
err6.cpp
err7.cpp
err8.cpp
err9.cpp
friend_demo.cpp
init_list.cpp
inner_demo.cpp
jz64.cpp
stackdepth.cpp
static_member.cpp
--- 文件个数 ---
24

编译命令一行一个文件,常用的选项是 -std=c++11 与 -Wall,把警告打开之后初始化顺序与未使用变量的提示都会显示出来。文件多的时候可以写成一个循环,把十二个能编过的文件一次跑完。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ g++ -std=c++11 -Wall -O0 -o init_list init_list.cpp
$ for f in init_list edge convert static_member dtor_order friend_demo inner_demo jz64 anon elide stackdepth composite; do
>   g++ -std=c++11 -Wall -O0 -o $f $f.cpp
> done

完整代码

本章其余演示程序的完整代码已经在核心知识各节里逐一给出,这里补齐用于复现报错的十二个文件。每个文件都只保留能触发报错的那几行,编译命令统一是 g++ -std=c++11 -Wall -o errN errN.cpp。

第一个文件里的类成员是一个没有默认构造函数的类型,构造函数没有把它写进初始化列表,编译器找不到可用的构造函数。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err1.cpp
#include <iostream>
using namespace std;

class Time
{
public:
	Time(int hour)
		: _hour(hour)
	{}

private:
	int _hour;
};

class Date
{
public:
	Date(int year)
		: _year(year)
	{}

private:
	int _year;
	Time _t;
};

int main()
{
	Date d(2024);
	return 0;
}

第二个文件的成员是一个引用,引用必须在构造的时候绑定到一个对象上,初始化列表里没有写它。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err2.cpp
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int year)
		: _year(year)
	{}

private:
	int _year;
	int& _ref;
};

int main()
{
	int year = 2025;
	Date d(year);
	return 0;
}

第三个文件的成员是一个 const 对象,同样必须在初始化列表里给出初值。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err3.cpp
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int year)
		: _year(year)
	{}

private:
	int _year;
	const int _n;
};

int main()
{
	Date d(2024);
	return 0;
}

第四个文件在类内的静态成员声明处直接写了初值,这个位置只允许声明。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err4.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A()
	{}

private:
	static int _scount = 0;
};

int main()
{
	A aa;
	return 0;
}

第五个文件在静态成员函数里直接访问了非静态成员,静态成员函数没有 this 指针。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err5.cpp
#include <iostream>
using namespace std;

class A
{
public:
	static int GetA()
	{
		return _a;
	}

private:
	int _a = 1;
};

int main()
{
	cout << A::GetA() << endl;
	return 0;
}

第六个文件的静态成员只有在类内的声明,没有写类外的定义,程序在链接阶段找不到这个符号。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err6.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A()
	{
		++_scount;
	}

	static int GetCount()
	{
		return _scount;
	}

private:
	static int _scount;
};

int main()
{
	A aa;
	cout << A::GetCount() << endl;
	return 0;
}

第七个文件里有一个普通函数访问了类的私有成员,类里没有把它声明成友元。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err7.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A(int a1 = 0)
		: _a1(a1)
	{}

private:
	int _a1;
};

void func(const A& aa)
{
	cout << aa._a1 << endl;
}

int main()
{
	A aa(1);
	func(aa);
	return 0;
}

第八个文件的构造函数加了 explicit,主函数里仍然用隐式转换的写法初始化对象。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err8.cpp
#include <iostream>
using namespace std;

class A
{
public:
	explicit A(int a = 0)
		: _a(a)
	{}

private:
	int _a;
};

int main()
{
	A aa1 = 1;
	return 0;
}

第九个文件把同一个成员在初始化列表里写了两次。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err9.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A(int a)
		: _a1(a)
		, _a1(a + 1)
	{}

private:
	int _a1;
};

int main()
{
	A aa(1);
	return 0;
}

第十个文件在成员声明处给了缺省值,这是一个 C++11 才支持的特性,用它配合 -std=c++98 编译时会看到不同等级的反应。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err10.cpp
#include <iostream>
using namespace std;

class A
{
public:
	void Print() const
	{
		cout << _a << endl;
	}

private:
	int _a = 1;
};

int main()
{
	A aa;
	aa.Print();
	return 0;
}

第十一个文件的内部类藏在外部类的 private 区域,类外面构造了它的对象。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err11.cpp
#include <iostream>
using namespace std;

class A
{
private:
	class Hidden
	{
	public:
		Hidden()
		{
			cout << "内部类构造" << endl;
		}
	};

public:
	A()
	{
		Hidden h;
	}
};

int main()
{
	A::Hidden h;
	return 0;
}

第十二个文件写成了 A aa1();,本意是构造一个对象,实际声明了一个返回 A 的函数。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/err12.cpp
#include <iostream>
using namespace std;

class A
{
public:
	A()
	{
		cout << "A 构造" << endl;
	}
};

int main()
{
	A aa1();
	cout << sizeof(aa1) << endl;
	return 0;
}

正常情况

十二个能编过的文件逐个编译,结果如下。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
[通过但有提示] init_list.cpp
init_list.cpp: In constructor 'Order::Order()':
init_list.cpp:100:7: warning: 'Order::_b' will be initialized after [-Wreorder]
[通过但有提示] edge.cpp
edge.cpp: In constructor 'Reorder::Reorder(int)':
edge.cpp:22:6: warning: 'Reorder::_a1' will be initialized after [-Wreorder]
[通过但有提示] convert.cpp
convert.cpp: In function 'int main()':
convert.cpp:87:4: warning: variable 'aa1' set but not used [-Wunused-but-set-variable]
[通过] static_member.cpp
[通过] dtor_order.cpp
[通过] friend_demo.cpp
[通过] inner_demo.cpp
[通过但有提示] jz64.cpp
jz64.cpp: In member function 'int Solution::Sum_Solution(int)':
jz64.cpp:21:7: warning: unused variable 'arr' [-Wunused-variable]
[通过] anon.cpp
[通过] elide.cpp
[通过] stackdepth.cpp
[通过] composite.cpp

十二个文件全部通过,其中四个文件带着编译提示。init_list.cpp 与 edge.cpp 的提示是初始化顺序与列表里的顺序不一致,convert.cpp 的提示是变量赋值之后没有使用,jz64.cpp 的提示是数组定义了没有使用。这四条都属于代码本身的问题,编译器的信息里直接给出了文件名与行号,按提示改写法即可。

抽三个程序看看运行时的输出,一个是静态局部对象的构造析构顺序,一个是友元关系的四条输出,一个是匿名对象的生命周期。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
$ ./dtor_order
C 构造
A 构造
B 构造
D 构造
main 的 return 之前
B 析构
A 析构
D 析构
C 析构
$ ./friend_demo
Func 读 A 的私有成员 1 与 2,读 B 的私有成员 3 与 4
B::Func1 读 A 的私有成员 1,读自己的 3
B::Func2 读 A 的私有成员 2,读自己的 4
C::Func 不是友元,只能走公开接口 3
$ ./anon
有名对象
A(int a) 构造,_a = 0
匿名对象 A()
A(int a) 构造,_a = 0
~A() 析构,_a = 0
匿名对象 A(1)
A(int a) 构造,_a = 1
~A() 析构,_a = 1
有名对象 A aa2(2)
A(int a) 构造,_a = 2
用匿名对象调一次成员函数
Sum_Solution 收到 10
10
把匿名对象的返回值接到 const 引用上
A(int a) 构造,_a = 7
ra.GetA() = 7
这一行之后 ra 还在用
main 的最后一行
~A() 析构,_a = 7
~A() 析构,_a = 2
~A() 析构,_a = 0

常见报错

十二个报错文件一起编译,编译器给出的第一条错误行如下。err10.cpp 是唯一编过的一个,它的表现放在下一节的边界情况里说明。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
[编不过] err1.cpp
err1.cpp:20:15: error: no matching function for call to 'Time::Time()'
[编不过] err2.cpp
err2.cpp:8:2: error: uninitialized reference member 'Date::_ref' [-fpermissive]
[编不过] err3.cpp
err3.cpp:8:2: error: uninitialized member 'Date::_n' with 'const' type 'const int' [-fpermissive]
[编不过] err4.cpp
err4.cpp:12:23: error: ISO C++ forbids in-class initialization of non-const static member 'A::_scount'
[编不过] err5.cpp
err5.cpp:14:11: error: invalid use of member 'A::_a' in static member function
err5.cpp:10:10: error: from this location
[编不过] err6.cpp
collect2: error: ld returned 1 exit status
[编不过] err7.cpp
err7.cpp:13:6: error: 'int A::_a1' is private
err7.cpp:18:13: error: within this context
[编不过] err8.cpp
err8.cpp:18:10: error: conversion from 'int' to non-scalar type 'A' requested
[编不过] err9.cpp
err9.cpp:8:2: error: multiple initializations given for 'A::_a1'
[编过] err10.cpp
[编不过] err11.cpp
err11.cpp:8:8: error: 'class A::Hidden' is private
err11.cpp:26:5: error: within this context
[编不过] err12.cpp
err12.cpp:17:20: error: ISO C++ forbids applying 'sizeof' to an expression of function type [-fpermissive]

成员没有默认构造函数时编不过。 类里的成员 _t 是 Time 类型,Time 只有一个带参数的构造函数,编译器在为 Date 生成构造函数时需要先构造 _t,找不到能用的构造函数就停在原地。报错里会把这个类型的候选构造函数一并列出,看到 candidate 那几行就可以确认是参数不匹配还是根本没有默认构造。解决办法是把 _t 写进 Date 的初始化列表。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err1.cpp: In constructor 'Date::Date(int)':
err1.cpp:20:15: error: no matching function for call to 'Time::Time()'
   : _year(year)
               ^
err1.cpp:20:15: note: candidates are:
err1.cpp:8:2: note: Time::Time(int)
  Time(int hour)
  ^
err1.cpp:8:2: note:   candidate expects 1 argument, 0 provided
err1.cpp:5:7: note: constexpr Time::Time(const Time&)
 class Time
       ^
err1.cpp:5:7: note:   candidate expects 1 argument, 0 provided
err1.cpp:5:7: note: constexpr Time::Time(Time&&)
err1.cpp:5:7: note:   candidate expects 1 argument, 0 provided

引用成员与 const 成员必须在初始化列表里初始化。 这两类成员没有默认状态,引用必须绑定到一个已经存在的对象,const 对象必须在出生时就有值,因此构造函数体里再赋值已经来不及。报错末尾的 -fpermissive 表示编译器允许把这条错误降级为警告继续编下去,代价是运行时的行为不再有保证。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err2.cpp: In constructor 'Date::Date(int)':
err2.cpp:8:2: error: uninitialized reference member 'Date::_ref' [-fpermissive]
  Date(int year)
  ^
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err3.cpp: In constructor 'Date::Date(int)':
err3.cpp:8:2: error: uninitialized member 'Date::_n' with 'const' type 'const int' [-fpermissive]
  Date(int year)
  ^

静态成员不能在类内声明处给初值。 静态成员不属于任何一个对象,它的定义只能出现在类外,因此类内这个位置只负责声明,写初值会被拒绝。报错信息里的 in-class initialization 指的就是这个位置。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err4.cpp:12:23: error: ISO C++ forbids in-class initialization of non-const static member 'A::_scount'
  static int _scount = 0;
                       ^

静态成员函数里访问非静态成员。 静态成员函数没有 this 指针,编译器在它里面解析 _a 这个名字时找不到绑定的对象,因此直接报无效使用。报错有两行,第二行 from this location 指出成员声明的位置,用来提示这个名字到底属于谁。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err5.cpp: In static member function 'static int A::GetA()':
err5.cpp:14:11: error: invalid use of member 'A::_a' in static member function
  int _a = 1;
           ^
err5.cpp:10:10: error: from this location
   return _a;
          ^
err5.cpp:11:2: warning: control reaches end of non-void function [-Wreturn-type]
  }
  ^

静态成员只声明不定义,错误会推迟到链接阶段。 编译阶段每个源文件各自看自己的语法,声明已经足够让它通过;链接器需要把所有用到的符号凑齐,找不到 _scount 的定义就报未定义的引用,末尾再加一行 collect2 的失败信息。这类报错的定位思路与编译错误不同,要去看哪个文件里漏了定义。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
/tmp/ccLaWHQC.o: In function `A::A()':
err6.cpp:(.text._ZN1AC2Ev[_ZN1AC5Ev]+0xa): undefined reference to `A::_scount'
err6.cpp:(.text._ZN1AC2Ev[_ZN1AC5Ev]+0x13): undefined reference to `A::_scount'
/tmp/ccLaWHQC.o: In function `A::GetCount()':
err6.cpp:(.text._ZN1A8GetCountEv[_ZN1A8GetCountEv]+0x6): undefined reference to `A::_scount'
collect2: error: ld returned 1 exit status

访问私有成员时缺少友元声明。 这条报错在友元一节里已经贴过,两条信息的组合很典型:一条说这个成员是私有的,另一条说访问发生在当前的上下文里。看到 within this context 时,需要确认的是发起访问的是哪个函数。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err7.cpp: In function 'void func(const A&)':
err7.cpp:13:6: error: 'int A::_a1' is private
  int _a1;
      ^
err7.cpp:18:13: error: within this context
  cout << aa._a1 << endl;
             ^

构造函数加了 explicit 之后的隐式转换。 这条报错在类型转换一节里已经贴过,编译器说的是没法把 int 转成 A,思路是补上显式写法,或者去掉 explicit。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err8.cpp: In function 'int main()':
err8.cpp:18:10: error: conversion from 'int' to non-scalar type 'A' requested
  A aa1 = 1;
          ^

同一个成员在初始化列表里出现两次。 报错信息里的 multiple initializations 直接点出问题,初始化列表里每个成员只能出现一次,写重复了编译器不能判断以哪一次为准。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err9.cpp: In constructor 'A::A(int)':
err9.cpp:8:2: error: multiple initializations given for 'A::_a1'
  A(int a)
  ^

类外面访问私有位置的内部类。 私有位置的内部类只在外部类的内部可见,类外面写 A::Hidden 会被当成访问私有成员,报错形态与访问私有变量一致。想让外面用到它,需要把它挪到 public 区域。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err11.cpp: In function 'int main()':
err11.cpp:8:8: error: 'class A::Hidden' is private
  class Hidden
        ^
err11.cpp:26:5: error: within this context
  A::Hidden h;
     ^

写成 A aa1(); 实际声明了一个函数。 这行代码不会构造任何对象,编译器把它解析成返回类型为 A、名为 aa1、没有参数的函数声明,因此后面用 sizeof(aa1) 的时候报错说不能对函数类型求大小。这类解析规则被称为最令人烦恼的解析,去掉那对空括号或者换成 A aa1; 即可绕开。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err12.cpp: In function 'int main()':
err12.cpp:17:20: error: ISO C++ forbids applying 'sizeof' to an expression of function type [-fpermissive]
  cout << sizeof(aa1) << endl;
                    ^

边界情况

标准档位决定了成员声明处缺省值是警告还是特性。 同一份代码在 -std=c++11 下正常编译并打印出预期结果,换成 -std=c++98 时编译器只给一条警告,说这个写法要 C++11 才支持,程序照样能跑,输出还是 1。警告与错误在这里的界限值得留意,语法上不认识的东西在 GCC 里可能先降级成警告。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
err10.cpp:14:11: warning: non-static data member initializers only available with -std=c++11 or -std=gnu++11 [enabled by default]
  int _a = 1;
           ^
$ ./err10_98
1

g++ 4.8.5 只认两个标准档位。 更高的档位在这个版本上连选项都不存在,编译器在解析命令行参数的时候就停下了,源代码根本没有被读进去。这一点解释了两件事:本机上无法验证 C++14 与 C++17 的新特性;判断一份代码能不能用某个特性,先看编译器支不支持对应的档位。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
g++: error: unrecognized command line option '-std=c++14'
g++: error: unrecognized command line option '-std=c++17'

未初始化的内置类型成员读出来的值随优化档位变化。 声明顺序与初始化列表顺序不一致时,编译器只给警告,程序照常编译,读到的值却是内存里的残留内容。-O0 与 -O2 下的数字不同,说明这个值本来就没有定义,加上 -O2 之后编译器多给出了一条未初始化的提示。换一个写法把它写得确定,比研究它在某个档位下的具体数字更值得做。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
edge.cpp: In constructor 'Reorder::Reorder(int)':
edge.cpp:22:6: warning: 'Reorder::_a1' will be initialized after [-Wreorder]
  int _a1;
      ^
edge.cpp:21:6: warning:   'int Reorder::_a2' [-Wreorder]
  int _a2;
      ^
edge.cpp:10:2: warning:   when initialized here [-Wreorder]
  Reorder(int a)
  ^
edge.cpp: In constructor 'Reorder::Reorder(int)':
edge.cpp:22:6: warning: 'Reorder::_a1' will be initialized after [-Wreorder]
  int _a1;
      ^
edge.cpp:21:6: warning:   'int Reorder::_a2' [-Wreorder]
  int _a2;
      ^
edge.cpp:10:2: warning:   when initialized here [-Wreorder]
  Reorder(int a)
  ^
edge.cpp: In function 'int main()':
edge.cpp:12:12: warning: 'r.Reorder::_a1' is used uninitialized in this function [-Wuninitialized]
   , _a2(_a1)
            ^
edge.cpp:43:10: note: 'r' was declared here
  Reorder r(1);
          ^
$ ./edge_O0
_a1 = 1,_a2 = 32765
_a = 1431655765,_b = 1431655765
这块内存事先被写成了 0x55,0x55555555 = 1431655765
sizeof(arr) = 12,数组名不退化

$ ./edge_O2
_a1 = 1,_a2 = 0
_a = 1431655765,_b = 1431655765
这块内存事先被写成了 0x55,0x55555555 = 1431655765
sizeof(arr) = 12,数组名不退化

把警告开到 -Wextra 也不会提醒未初始化的成员。 GCC 的两个警告档位都没有覆盖到这件事,因此不能指望编译器的提示来发现它,初始化列表要写满写全。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
edge.cpp: In constructor 'Reorder::Reorder(int)':
edge.cpp:22:6: warning: 'Reorder::_a1' will be initialized after [-Wreorder]
  int _a1;
      ^
edge.cpp:21:6: warning:   'int Reorder::_a2' [-Wreorder]
  int _a2;
      ^
edge.cpp:10:2: warning:   when initialized here [-Wreorder]
  Reorder(int a)
  ^
edge.cpp: In function 'int main()':
edge.cpp:12:12: warning: 'r.Reorder::_a1' is used uninitialized in this function [-Wuninitialized]
   , _a2(_a1)
            ^
edge.cpp:43:10: note: 'r' was declared here
  Reorder r(1);
          ^
(到这里没有输出就说明编译器没有提醒)

变长数组属于扩展,-Wall 与 -pedantic 给出的反应不同。 数组长度是运行时才确定的参数,标准里不允许,GCC 把它当扩展接受。只开 -Wall 时提示的是数组定义了没有使用,加上 -pedantic 才会看到标准层面的那条警告。

text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo
jz64.cpp: In member function 'int Solution::Sum_Solution(int)':
jz64.cpp:21:7: warning: unused variable 'arr' [-Wunused-variable]
   Sum arr[n];
       ^
$ ./jz64
Sum_Solution(10) = 55
Sum_Solution(100) = 6105

jz64.cpp: In member function 'int Solution::Sum_Solution(int)':
jz64.cpp:21:12: warning: ISO C++ forbids variable length array 'arr' [-Wvla]
   Sum arr[n];
            ^
jz64.cpp:21:7: warning: unused variable 'arr' [-Wunused-variable]
   Sum arr[n];
       ^
(上面是加 -pedantic 之后的输出)

实测数据

下面四张表是从上面的回显里整理出来的对照数据,运行环境是 CentOS 7.9.2009 与 g++ 4.8.5,编译选项均为 -std=c++11。

第一张表是拷贝省略的三档对照,四个数字依次是构造、拷贝构造、赋值与析构的次数。

用例 -O0 与 -O2 -O0 -fno-elide-constructors
有名对象传值传参 1 / 1 / 0 / 1 1 / 1 / 0 / 1
隐式转换传值传参 1 / 0 / 0 / 1 1 / 1 / 0 / 2
匿名对象传值传参 1 / 0 / 0 / 1 1 / 1 / 0 / 2
const 引用传参 1 / 0 / 0 / 0 1 / 0 / 0 / 0
const 引用传匿名 1 / 0 / 0 / 1 1 / 0 / 0 / 1
返回值丢掉 1 / 0 / 0 / 1 1 / 1 / 0 / 2
返回值初始化新对象 1 / 0 / 0 / 0 1 / 2 / 0 / 2
返回值赋给已有对象 2 / 0 / 1 / 1 2 / 1 / 1 / 2

第二张表是对象与静态成员在内存里的对照,数据来自 static_member.cpp 与 inner_demo.cpp 的运行结果。

观察项 实测值 说明
sizeof(A),类里有一个 int 成员与一份静态计数 4 静态成员不占对象空间
对象 aa1 的地址 0x7ffc770b5960 栈上的地址
静态成员 _scount 的地址 0x6021b4 数据段里的地址
nm 里 _scount 的符号类型 B 未初始化数据段
sizeof 静态成员函数指针 8 与普通函数指针相同
sizeof 非静态成员函数指针 16 需要额外的信息来定位对象
sizeof(A::B),内部类 4 内部类不占外部类对象的空间

第三张表是 gdb 在两个构造函数上下断点后的命中次数,程序是 stackdepth.cpp。

编译档位 A::A(int) 命中 A::A(const A&) 命中 多出来的调用来自哪里
-O0 -g 1 1 没有多余调用
-O0 -g -fno-elide-constructors 1 3 Make 的返回处一次,main 里初始化 a1 处两次

第四张表是同一份代码在不同标准档位下的结果,代码是 err10.cpp。

编译选项 结果
-std=c++98 编译通过,成员声明处的缺省值给出一条警告
-std=c++11 编译通过,缺省值正常生效,输出 1
-std=c++14 g++ 报 unrecognized command line option,源代码没有被编译
-std=c++17 与 -std=c++14 相同

实例

最后一个程序把本章的主要知识点串在一起:静态成员变量统计存活数量,静态成员函数读出这个数量,内部类读取外部类的私有静态成员,匿名对象在一行之内构造和析构,const 引用接住临时对象,排序时传入一个匿名对象当比较器。

cpp 复制代码
// 文件:/home/cocatrice/cpp05/demo/composite.cpp
#include <iostream>
#include <algorithm>
#include <functional>
using namespace std;

class Tracked
{
public:
	Tracked(int id = 0)
		: _id(id)
	{
		++_alive;
		cout << "构造 Tracked(" << _id << "),存活 " << _alive << " 个" << endl;
	}

	Tracked(const Tracked& t)
		: _id(t._id)
	{
		++_alive;
		++_copies;
		cout << "拷贝构造 Tracked(" << _id << "),存活 " << _alive
			<< " 个,累计拷贝 " << _copies << " 次" << endl;
	}

	~Tracked()
	{
		--_alive;
	}

	int GetId() const
	{
		return _id;
	}

	static int GetAlive()
	{
		return _alive;
	}

	static int GetCopies()
	{
		return _copies;
	}

	class Guard
	{
	public:
		Guard()
		{
			cout << "Guard 记录下的存活数 " << _alive << endl;
		}

		int Alive() const
		{
			return _alive;
		}
	};

private:
	int _id;
	static int _alive;
	static int _copies;
};

int Tracked::_alive = 0;
int Tracked::_copies = 0;

int main()
{
	Tracked t1(1);
	cout << "---- 内部类 Guard 读外部类的私有静态成员 ----" << endl;
	Tracked::Guard g;
	cout << "Guard::Alive() = " << g.Alive() << endl;

	cout << "---- 匿名对象调一次成员函数 ----" << endl;
	cout << "匿名对象读到 " << Tracked(9).GetId() << endl;
	cout << "调用之后存活 " << Tracked::GetAlive() << " 个" << endl;

	cout << "---- const 引用接住一个匿名对象 ----" << endl;
	const Tracked& r9 = Tracked(7);
	cout << "r9.GetId() = " << r9.GetId() << endl;

	cout << "---- 用匿名对象做比较器排序 ----" << endl;
	int a[] = { 32, 71, 12, 45, 26, 80, 53, 33 };
	sort(a, a + 8, greater<int>());
	for (int i = 0; i < 8; ++i)
	{
		cout << a[i] << ' ';
	}
	cout << endl;

	cout << "---- 传值传参与 const 引用传参 ----" << endl;
	Tracked t2(2);
	cout << "传值之前累计拷贝 " << Tracked::GetCopies() << " 次" << endl;
	Tracked a1 = t2;
	Tracked& r2 = t2;
	cout << "传值之后累计拷贝 " << Tracked::GetCopies() << " 次" << endl;
	cout << "r2.GetId() = " << r2.GetId() << ",a1.GetId() = " << a1.GetId() << endl;
	cout << "程序结束时存活 " << Tracked::GetAlive() << " 个" << endl;
	return 0;
}
text 复制代码
# 所属目录:/home/cocatrice/cpp05/demo

$ ./composite
构造 Tracked(1),存活 1 个
---- 内部类 Guard 读外部类的私有静态成员 ----
Guard 记录下的存活数 1
Guard::Alive() = 1
---- 匿名对象调一次成员函数 ----
构造 Tracked(9),存活 2 个
匿名对象读到 9
调用之后存活 1 个
---- const 引用接住一个匿名对象 ----
构造 Tracked(7),存活 2 个
r9.GetId() = 7
---- 用匿名对象做比较器排序 ----
80 71 53 45 33 32 26 12 
---- 传值传参与 const 引用传参 ----
构造 Tracked(2),存活 3 个
传值之前累计拷贝 0 次
拷贝构造 Tracked(2),存活 4 个,累计拷贝 1 次
传值之后累计拷贝 1 次
r2.GetId() = 2,a1.GetId() = 2
程序结束时存活 4 个

这段程序跑出来的输出可以分成下面五段来看。第一段是单个对象的构造,_alive 从 0 变成 1。第二段用了内部类 Guard,它在自己的构造函数里读到外部类的私有静态成员 _alive,读到的是 1,说明内部类确实拥有访问外部类私有成员的权限。第三段是匿名对象 Tracked(9).GetId(),这一行构造了一个临时对象、读到它的成员、随即析构,之后存活数回到 1,这正是匿名对象生命周期只在一行的体现。第四段把匿名对象交给 const 引用,const Tracked& r9 = Tracked(7); 之后 r9 仍然可用,读到 7。第五段先构造 t2,再用它初始化 a1,累计拷贝从 0 变成 1,而 r2 是引用,引用本身不产生拷贝,最后程序结束时存活 4 个对象,也就是 t1、r9 接住的临时对象、t2 与 a1。

踩坑点

  • 初始化列表的书写顺序与成员的初始化顺序无关,实际顺序按类里的声明顺序走,顺序反了结果就反了。
  • 引用成员、const 成员与没有默认构造函数的类类型成员必须写进初始化列表,放进构造函数体里已经来不及。
  • 声明顺序与列表顺序不一致时 -Wall 只给 -Wreorder 警告,程序照样编过,读到的值不确定。
  • 内置类型成员漏在初始化列表外面时,-O0 与 -O2 读到的值不同,-Wall 与 -Wextra 都不会提醒这件事。
  • 成员声明处的缺省值属于 C++11 及以后的特性,用 -std=c++98 编译时编译器只给一条警告,程序仍然照跑。
  • 静态成员只在类内声明而忘了写类外定义时,错误推迟到链接阶段,报错末尾是 collect2: error: ld returned 1 exit status。
  • 静态成员在类内声明处直接给初值会被拒绝,这个位置只负责声明。
  • 静态成员函数没有 this 指针,直接访问非静态成员会得到 invalid use of member。
  • 静态成员函数既可以用对象调用也可以用类名调用,两种写法的结果相同,类名调用更能说明它不属于某个对象。
  • 静态成员的名字待在类域里,类外访问要带上 类名:: 限定,不能只写成员名。
  • 友元关系是单向的,A 把 B 声明为友元只说明 B 能访问 A 的私有成员。
  • 友元关系不能传递,A 是 B 的友元、B 是 C 的友元,A 与 C 之间没有任何权限关系。
  • 友元函数不是类的成员函数,它没有 this 指针,要访问的对象必须从参数传进来。
  • 友元声明可以写在类里的任何位置,放在 private 区域与放在 public 区域效果相同。
  • 内部类是独立的类,外部类的对象里不包含它的成员,用 sizeof 可以直接验证这一点。
  • 内部类默认能访问外部类的私有成员,但它没有外部类的 this,外部对象仍然要从参数传进来。
  • 放在 private 区域的内部类,类外面既不能构造它的对象,也不能使用它的名字。
  • 匿名对象的生命周期只到这一行结束,构造与析构发生在同一行里。
  • 把临时对象绑定到 const 引用可以延长它的生命周期,非 const 引用绑不上临时对象。
  • 拷贝省略在 -O0 下同样发生,调低优化档位并不能阻止它。
  • 拷贝省略省掉的是调用,不是检查,拷贝构造函数该可用还是得可用。
  • 用打印或静态计数统计构造次数时,省略会让计数偏小,要观察真实次数得加 -fno-elide-constructors。
  • A aa1(); 会被解析成函数声明,这行代码不会构造任何对象,要写 A aa1;。
  • 变长数组 Sum arr[n] 是 GCC 的扩展,-Wall 不提示,加上 -pedantic 才会看到标准层面的警告。
  • 静态成员的状态不会随着函数返回而复位,第二次调用会接着上一次的结果继续累积。

本篇总结(模拟面试问题)

问:初始化列表的初始化顺序由什么决定?

核心要点:按成员在类里的声明顺序执行,与列表里的书写顺序无关;顺序不一致时编译器给出 -Wreorder 警告但不影响编译;建议把两者的顺序写成一致。

问:为什么引用成员与 const 成员必须放进初始化列表?

核心要点:这两类成员没有默认状态,引用要绑定到一个已经存在的对象,const 对象要在出生时就有值;构造函数体执行的时候它们已经必须存在,因此只能放在初始化列表里。

问:成员声明处的缺省值什么时候生效?

核心要点:C++11 起支持这个写法;它作用的对象是那些没有出现在初始化列表里的成员;在 -std=c++98 下编译器只给一条警告,程序仍然能编过并跑出结果。

问:静态成员变量存放在哪里,使用它要满足什么条件?

核心要点:所有对象共用一份,存放在静态存储区,对象的内存里没有它的位置;类内写声明,类外写定义与初始值;漏掉类外定义时错误在链接阶段出现。

问:静态成员函数与普通成员函数有什么区别?

核心要点:静态成员函数没有 this 指针,不能访问非静态成员,可以用类名加作用域限定直接调用;静态成员函数指针占 8 字节,非静态成员函数指针占 16 字节。

问:静态成员有哪些典型用途?

核心要点:统计存活对象个数、给对象发号、充当整个类共用的状态;静态局部对象只构造一次,适合做延迟初始化;代价是引入了全局状态,同步要自己保证。

问:友元关系有哪些限制?

核心要点:单向、不可传递;友元函数不是成员函数,没有 this 指针,对象要从参数传入;声明位置不受访问限定符影响;代价是耦合度上升。

问:内部类与外部类之间是什么关系?

核心要点:内部类是一个独立的类,不受外部类对象大小的影响;它默认是外部类的友元,可以访问外部类的私有成员;放在 private 区域之后,它就只归外部类使用。

问:匿名对象的生命周期有多长?

核心要点:没有名字,生命周期只到这一行结束,构造与析构在同一行完成;绑定到 const 引用之后生命周期延长到引用的作用域结束;排序时传入的 greater<int>() 就是匿名对象。

问:什么是拷贝省略,它在哪些场合发生?

核心要点:标准允许编译器跳过临时对象的拷贝构造与析构;常见于临时对象初始化同类型对象、按值返回临时对象、按值传参且实参是临时对象这三类场合;-O0 与 -O2 都会发生,-fno-elide-constructors 可以关掉。

问:为什么统计构造次数的实验会得到不同的数字?

核心要点:编译器合法地省掉了拷贝构造的调用;打印计数与 gdb 断点计数都会偏小;gdb 的调用栈可以直接看出多出来的拷贝挂在哪个函数上。

问:g++ 4.8.5 在标准支持上有什么限制?

核心要点:只认 -std=c++98 与 -std=c++11,-std=c++14 与 -std=c++17 会报选项不认识;C++17 起一部分拷贝省略从允许改成必须,本机没有办法给出实测对照。

参考

相关推荐
青春逝如流水1 小时前
Java 线程在内存模型(JMM)与 JVM 实现:规范与落地的多元路径
java·开发语言·jvm·多线程·ai文章
weixin_460443566 小时前
2026年新修订《网络安全法》实施:Java私有化考试系统如何做好SBOM、漏洞治理与安全升级?
java·开发语言·在线考试系统·企业培训考试系统·宏远培训考试系统·国企培训考试系统·国产化培训系统
Cc.Y9 小时前
Java零基础入门:可变字符串与包装类:StringBuilder、StringBuffer 与 通讯录管理系统实战
java·开发语言·python
程序员-Benothing9 小时前
Vim编辑器从入门到精通:模式、快捷键、配置、插件
linux·运维·服务器
sm_9267870510 小时前
RFID 标签打印的技术实现要点与二次开发实践
java·大数据·前端·c++·编辑器
阿森_dev10 小时前
2027 届毕设选题怎么选:30 个 Java 方向选题的实现难度排序
java·开发语言·spring boot·毕业设计·课程设计
by2099910 小时前
C++ 多态:同一个基类接口,调用不同的函数
开发语言·c++·经验分享·多态
笔墨登场说说10 小时前
flink bin/start-cluster.sh 帮我做成开机启动
开发语言·python
Sarvartha10 小时前
Lambda 表达式和函数式接口
java·开发语言