C++ 类和对象入门(五):初始化列表、explicit 和 static 成员详解

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 ✨ 数据即知识,压缩即智能
目录
- [C++ 类和对象入门(五):初始化列表、explicit 和 static 成员详解](#C++ 类和对象入门(五):初始化列表、explicit 和 static 成员详解)
-
- 前言
- 一、再探构造函数:为什么需要初始化列表?
-
- [1.1 函数体内赋值不是"真正初始化"](#1.1 函数体内赋值不是“真正初始化”)
- [1.2 初始化列表的基本语法](#1.2 初始化列表的基本语法)
- [1.3 初始化列表和函数体赋值有什么区别?](#1.3 初始化列表和函数体赋值有什么区别?)
- 二、必须使用初始化列表的三类成员
-
- [2.1 引用成员变量必须在初始化列表初始化](#2.1 引用成员变量必须在初始化列表初始化)
- [2.2 const 成员变量必须在初始化列表初始化](#2.2 const 成员变量必须在初始化列表初始化)
- [2.3 没有默认构造函数的自定义类型成员必须在初始化列表初始化](#2.3 没有默认构造函数的自定义类型成员必须在初始化列表初始化)
- [三、C++11 成员变量声明处的缺省值](#三、C++11 成员变量声明处的缺省值)
-
- [3.1 声明处的值不是直接初始化](#3.1 声明处的值不是直接初始化)
- [3.2 声明处缺省值的价值](#3.2 声明处缺省值的价值)
- 四、初始化顺序:不是看初始化列表顺序
-
- [4.1 一个经典例子](#4.1 一个经典例子)
- [4.2 正确建议](#4.2 正确建议)
- 五、类型转换:构造函数不只是用来构造对象
-
- [5.1 内置类型可以隐式转换成类类型](#5.1 内置类型可以隐式转换成类类型)
- [5.2 多参数构造函数也可能参与转换](#5.2 多参数构造函数也可能参与转换)
- 六、explicit:禁止隐式类型转换
-
- [6.1 为什么需要 explicit?](#6.1 为什么需要 explicit?)
- [6.2 explicit 的使用建议](#6.2 explicit 的使用建议)
- [七、static 成员:属于类,不属于某个对象](#七、static 成员:属于类,不属于某个对象)
-
- [7.1 什么是 static 成员变量?](#7.1 什么是 static 成员变量?)
- [7.2 静态成员变量要在类外定义初始化](#7.2 静态成员变量要在类外定义初始化)
- [7.3 static 成员函数没有 this 指针](#7.3 static 成员函数没有 this 指针)
- [八、用 static 实现对象计数](#八、用 static 实现对象计数)
-
- [8.1 基本思路](#8.1 基本思路)
- [8.2 static 成员的访问方式](#8.2 static 成员的访问方式)
- [九、用 static 解经典题:求 1 + 2 + ... + n](#九、用 static 解经典题:求 1 + 2 + ... + n)
- 十、本文总结
前言
学到 C++ 类和对象的后半部分,很多同学会发现一个现象:
前面已经学过构造函数了,为什么后面还要"再探构造函数"?
原因很简单。
前面我们写构造函数时,通常是在函数体里面给成员变量赋值:
cpp
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
这段代码能用,也很直观。
但是从 C++ 的对象初始化机制来看,它并不是最完整、最推荐的写法。
因为在 C++ 中,对象成员不是进入构造函数体之后才"出生"的。
它们在构造函数体执行之前,就已经要完成初始化了。
这就引出了一个非常重要的语法:
构造函数初始化列表。
这一篇主要讲三个内容:
- 构造函数初始化列表
- 类型转换与
explicit static静态成员
一、再探构造函数:为什么需要初始化列表?
1.1 函数体内赋值不是"真正初始化"
先看一个普通构造函数:
cpp
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
很多初学者会把这段代码理解成:
构造函数里初始化了
_year、_month、_day。
从效果上看,好像没错。
但是严格来说,函数体里面这三句更接近"赋值",而不是"初始化"。
对象创建时,成员变量会先经过初始化阶段,然后才进入构造函数体执行。
也就是说,构造函数体里的代码不是成员变量初始化的起点。
真正负责成员初始化的地方,是初始化列表。
1.2 初始化列表的基本语法
初始化列表写在构造函数参数列表后面,以冒号开始,多个成员之间用逗号分隔。
cpp
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{
}
private:
int _year;
int _month;
int _day;
};
这一段:
cpp
: _year(year)
, _month(month)
, _day(day)
就是初始化列表。
可以把它理解成:
每个成员变量真正被定义初始化的地方。
构造函数体 {} 中的内容,更多是对象初始化完成之后要执行的逻辑。
1.3 初始化列表和函数体赋值有什么区别?
看起来下面两种写法结果差不多:
cpp
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
和:
cpp
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{
}
对于 int 这种内置类型成员,在很多简单场景中确实差别不明显。
但如果成员变量是自定义类型、引用、const,区别就非常关键了。
可以简单理解:
- 初始化列表:成员出生时就拿到正确的值;
- 函数体赋值:成员已经出生了,再给它重新赋值。
这就像填身份证信息。
初始化列表像是出生登记时直接写对。
函数体赋值像是先生成一个默认信息,再去修改它。
普通成员可能还好,但有些成员根本不能"先默认出生,再修改"。

二、必须使用初始化列表的三类成员
2.1 引用成员变量必须在初始化列表初始化
引用有一个基本规则:
引用定义时必须初始化。
所以如果类里有引用成员:
cpp
class Date
{
private:
int& _ref;
};
它必须在初始化列表中绑定一个对象。
cpp
class Date
{
public:
Date(int& x)
: _ref(x)
{
}
private:
int& _ref;
};
不能写成:
cpp
Date(int& x)
{
_ref = x;
}
因为进入构造函数体时,_ref 这个引用成员已经必须完成初始化了。
引用一旦没在初始化列表中绑定对象,编译器就会报错。
2.2 const 成员变量必须在初始化列表初始化
const 成员变量也类似。
cpp
class Date
{
private:
const int _n;
};
const 对象一旦创建,就不能再被修改。
所以它也必须在初始化列表中初始化:
cpp
class Date
{
public:
Date()
: _n(1)
{
}
private:
const int _n;
};
不能指望在构造函数体里写:
cpp
_n = 1;
因为这已经不是初始化,而是赋值。
而 const 成员不允许被赋值修改。
2.3 没有默认构造函数的自定义类型成员必须在初始化列表初始化
再看一个自定义类型成员。
cpp
class Time
{
public:
Time(int hour)
: _hour(hour)
{
}
private:
int _hour;
};
Time 类只有一个带参构造函数,没有默认构造函数。
如果另一个类中包含 Time 成员:
cpp
class Date
{
private:
Time _t;
};
创建 Date 对象时,_t 也要被构造。
但是 Time 没有无参构造函数,编译器不知道该怎么默认构造 _t。
所以必须在 Date 的初始化列表中明确写:
cpp
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
, _t(12)
{
}
private:
int _year;
int _month;
int _day;
Time _t;
};

三、C++11 成员变量声明处的缺省值
3.1 声明处的值不是直接初始化
C++11 支持在成员变量声明的位置给缺省值。
cpp
class Date
{
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
这让代码看起来很像"在类里初始化成员变量"。
但要注意:
这里给的是缺省值,不是对象创建时一定直接执行的初始化语句。
它的作用是:
如果某个成员没有在初始化列表中显式初始化,那么初始化列表会使用这个声明处的缺省值。
比如:
cpp
class Date
{
public:
Date()
: _month(2)
{
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
创建对象时:
cpp
Date d;
结果是:
cpp
_year = 1
_month = 2
_day = 1
因为 _month 在初始化列表中被显式初始化成 2,所以声明处的 _month = 1 不再生效。
而 _year 和 _day 没有出现在初始化列表里,就使用声明处的缺省值。
3.2 声明处缺省值的价值
这个语法很实用。
它可以让成员变量有一个明确的兜底值,避免对象默认构造时成员状态混乱。
例如:
cpp
class Stack
{
private:
int* _a = nullptr;
size_t _top = 0;
size_t _capacity = 0;
};
这样即使某些构造函数没有显式初始化这些成员,它们也有比较安全的默认状态。
当然,声明处缺省值不能完全替代初始化列表。
对于需要根据构造函数参数初始化的成员,仍然应该写在初始化列表中。
四、初始化顺序:不是看初始化列表顺序
4.1 一个经典例子
看下面代码:
cpp
class A
{
public:
A(int a)
: _a1(a)
, _a2(_a1)
{
}
void Print()
{
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2 = 2;
int _a1 = 2;
};
很多人会觉得输出是:
cpp
1 1
因为初始化列表里先写了:
cpp
_a1(a)
再写:
cpp
_a2(_a1)
但真实情况并不是这样。
成员变量的初始化顺序,不取决于初始化列表中的书写顺序。
它取决于成员变量在类中的声明顺序。
这里类中声明顺序是:
cpp
int _a2 = 2;
int _a1 = 2;
所以先初始化 _a2,再初始化 _a1。
当 _a2(_a1) 执行时,_a1 还没有被初始化成 a。
这就可能得到随机值或不符合预期的结果。

4.2 正确建议
为了避免这类坑,建议:
成员变量声明顺序和初始化列表书写顺序保持一致。
比如改成:
cpp
class A
{
public:
A(int a)
: _a1(a)
, _a2(_a1)
{
}
private:
int _a1 = 2;
int _a2 = 2;
};
这样代码的阅读顺序和真实初始化顺序一致,就不容易误判。
五、类型转换:构造函数不只是用来构造对象
5.1 内置类型可以隐式转换成类类型
C++ 中,如果一个类有单参数构造函数,就可能发生隐式类型转换。
例如:
cpp
class A
{
public:
A(int a)
: _a(a)
{
}
private:
int _a;
};
此时可以写:
cpp
A aa = 1;
这句话可以理解成:
先用
1构造一个A类型的临时对象,再用这个临时对象初始化aa。
在现代编译器中,这个过程经常会被优化成直接构造。
所以它看起来像是 int 自动变成了 A 对象。
5.2 多参数构造函数也可能参与转换
C++11 之后,花括号初始化让多参数构造函数也能参与类似转换。
cpp
class A
{
public:
A(int a1, int a2)
: _a1(a1)
, _a2(a2)
{
}
private:
int _a1;
int _a2;
};
可以写:
cpp
A aa = { 1, 2 };
这会调用两个参数的构造函数。
这种写法简洁,但有时也可能让代码过于"自动"。
如果一个转换不是特别自然,就应该限制它。
六、explicit:禁止隐式类型转换
6.1 为什么需要 explicit?
隐式类型转换有时方便,有时危险。
比如:
cpp
A aa = 1;
如果这个转换是你希望的,那没问题。
但如果类的构造逻辑比较复杂,或者从 int 到 A 的语义并不明确,允许自动转换可能会让代码可读性下降。
这时可以在构造函数前加 explicit:
cpp
class A
{
public:
explicit A(int a)
: _a(a)
{
}
private:
int _a;
};
这样下面写法就不允许了:
cpp
A aa = 1;
必须显式写:
cpp
A aa(1);
或者:
cpp
A aa{1};
6.2 explicit 的使用建议
一般来说:
如果一个构造函数表示的是非常自然的类型转换,可以不加 explicit。
如果这个构造函数只是为了创建对象,而不是为了表达"某类型可以自然变成当前类",就建议加 explicit。
实际工程中,很多类的单参数构造函数都会加 explicit,避免意外隐式转换。
不想让编译器偷偷帮你转,就给构造函数加 explicit。

七、static 成员:属于类,不属于某个对象
7.1 什么是 static 成员变量?
类中用 static 修饰的成员变量,叫静态成员变量。
它最大的特点是:
静态成员变量属于整个类,所有对象共享一份,不属于某个具体对象。
例如:
cpp
class A
{
private:
static int _count;
};
这个 _count 不是每个对象各自拥有一份,而是所有 A 对象共享一份。
它通常用来记录和整个类相关的信息。
比如:
- 当前创建了多少个对象;
- 某个类的全局配置;
- 所有对象共享的统计数据。
7.2 静态成员变量要在类外定义初始化
静态成员变量在类中只是声明。
通常还需要在类外定义初始化:
cpp
class A
{
private:
static int _count;
};
int A::_count = 0;
这里:
cpp
int A::_count = 0;
才是真正给静态成员变量分配空间并初始化。
注意:
静态成员变量不属于某个对象,所以它不走构造函数初始化列表。
这也是为什么普通静态成员变量不能像非静态成员那样依赖初始化列表初始化。
7.3 static 成员函数没有 this 指针
静态成员函数用 static 修饰:
cpp
class A
{
public:
static int GetCount()
{
return _count;
}
private:
static int _count;
};
静态成员函数也属于类,而不是某个具体对象。
所以它没有 this 指针。
这就带来一个限制:
静态成员函数只能直接访问静态成员,不能直接访问非静态成员。
因为非静态成员属于具体对象。
而静态成员函数没有 this,不知道你要访问哪个对象的非静态成员。

八、用 static 实现对象计数
8.1 基本思路
我们可以用 static 成员统计当前程序中创建了多少个对象。
cpp
class A
{
public:
A()
{
++_count;
}
A(const A& a)
{
++_count;
}
~A()
{
--_count;
}
static int GetCount()
{
return _count;
}
private:
static int _count;
};
int A::_count = 0;
这里:
- 构造对象时,
_count++; - 拷贝构造对象时,
_count++; - 析构对象时,
_count--; - 通过
GetCount()获取当前对象数量。
8.2 static 成员的访问方式
静态成员可以通过类名访问:
cpp
cout << A::GetCount() << endl;
也可以通过对象访问:
cpp
A a;
cout << a.GetCount() << endl;
但更推荐用类名访问。
因为静态成员属于类,用类名访问语义更清楚。
九、用 static 解经典题:求 1 + 2 + ... + n
有些题会限制不能使用循环、递归、乘除法等。
这时可以利用对象数组和静态成员。
核心思路是:
cpp
class Sum
{
public:
Sum()
{
_ret += _i;
++_i;
}
static int GetRet()
{
return _ret;
}
private:
static int _i;
static int _ret;
};
然后创建 n 个对象:
cpp
Sum arr[n];
每创建一个对象,构造函数就会自动执行一次。
第 1 个对象加 1。
第 2 个对象加 2。
第 3 个对象加 3。
最后得到:
cpp
1 + 2 + 3 + ... + n
这个例子很适合理解:
static 成员是所有对象共享的,而构造函数会在每个对象创建时自动调用。

十、本文总结
这一篇主要讲了类和对象的三个重点:
第一,初始化列表。
它是成员变量真正初始化的地方。
引用成员、const 成员、没有默认构造的自定义类型成员,必须在初始化列表中初始化。
初始化顺序由成员变量在类中的声明顺序决定,而不是由初始化列表中的书写顺序决定。
第二,类型转换和 explicit。
构造函数可以让内置类型隐式转换为类类型对象。
explicit 可以禁止这种隐式转换,让对象创建更加明确。
第三,static 成员。
静态成员变量属于类,所有对象共享一份。
静态成员函数没有 this 指针,只能直接访问静态成员。
如果用一句话总结:
初始化列表解决"成员怎么出生",explicit 解决"对象能不能被偷偷转换",static 解决"哪些数据应该属于类而不是对象"。