上一篇 C++ 类与对象(中) 讲了六大默认成员函数。这一篇聚焦构造函数的高级用法、类级共享机制、打破封装的友元,以及编译器在你背后做的拷贝优化。

再探构造函数:初始化列表

为什么需要初始化列表

中篇写的构造函数,成员变量是在函数体内赋值的:

Date(int year, int month, int day) {
    _year = year;     // 这是赋值,不是初始化
    _month = month;
    _day = day;
}

_year = year 这步发生在函数体里,意味着在进入 { 之前,_year 已经走完了一轮初始化——只不过内置类型的"初始化"可能什么都没做(值不确定)。C++ 提供了初始化列表(Initializer List),让成员变量在进入函数体之前就完成真正的初始化。

初始化列表以一个冒号开始,逗号分隔:

Date(int year, int month, int day)
    : _year(year)       // 这是真正的初始化
    , _month(month)
    , _day(day)
{
    // 函数体可以为空——所有工作已在初始化列表完成
}

强制使用初始化列表的三种情况

三种成员必须放在初始化列表中初始化,函数体赋值不行:

#include <iostream>

class Time {
public:
    Time(int hour) : _hour(hour) {     // 只有带参构造,没有默认构造
        std::cout << "Time()" << std::endl;
    }
private:
    int _hour;
};

class Date {
public:
    Date(int& x, int year = 1, int month = 1, int day = 1)
        : _year(year)
        , _month(month)
        , _day(day)
        , _t(12)           // 【必需一】没有默认构造的类类型成员——必须显式调用其构造函数
        , _ref(x)          // 【必需二】引用成员变量——必须在定义时绑定
        , _n(1)            // 【必需三】const 成员变量——初始化后不能修改
    {
        // 以下三行如果放在函数体内,编译器直接报错:
        // _t = Time(12);  // error: Time 没有默认构造
        // _ref = x;       // error: 引用必须初始化
        // _n = 1;         // error: const 变量必须初始化
    }

private:
    int _year;
    int _month;
    int _day;
    Time _t;          // 没有默认构造函数
    int& _ref;        // 引用
    const int _n;     // const
};

int main() {
    int i = 0;
    Date d1(i);
    return 0;
}

一句话记死:引用、const、无默认构造的类类型——这三类成员的生命必须在初始化列表中开始。

C++11 声明处缺省值

C++11 允许在成员变量声明位置直接给缺省值。这个缺省值不是"初始化",而是给初始化列表兜底的——如果初始化列表没有显式初始化某个成员,编译器就用这个缺省值。

class Date {
public:
    Date() : _month(2) {   // _month 用 2 初始化(覆盖声明处的缺省值 1)
        std::cout << "Date()" << std::endl;
    }

    void Print() const {
        std::cout << _year << "-" << _month << "-" << _day << std::endl;
    }

private:
    int _year = 1;         // 缺省值——初始化列表没管 _year 时生效
    int _month = 1;        // 但被初始化列表中的 :_month(2) 覆盖了
    int _day;              // 没有缺省值,又没有在初始化列表中显式初始化——值不确定
};

// 输出: 1-2-随机值

初始化顺序陷阱

初始化列表中成员的初始化顺序不是按照列表中的书写顺序,而是按照成员在类中声明的顺序

class A {
public:
    A(int a)
        : _a2(_a1)         // ⚠️ 意图:用 _a1 初始化 _a2
        , _a1(a)
    {}

    void Print() {
        std::cout << _a1 << " " << _a2 << std::endl;
    }

private:
    int _a2 = 2;           // 声明顺序:_a2 在前
    int _a1 = 2;           // 声明顺序:_a1 在后
};

int main() {
    A aa(1);
    aa.Print();            // 输出: 1 随机值(不是 1 1!)
}

_a2 声明在 _a1 前面,初始化列表中 _a2(_a1) 先执行——此时 _a1 还没被初始化,值是随机的。初始化列表的顺序保持与声明顺序一致,这是一条不容妥协的规则。

📖 参考:《高质量C++/C编程指南》第9章:成员对象的初始化次序由类声明中的声明次序决定,与初始化表中的书写次序无关。

初始化列表总结

无论你是否显式写了初始化列表,构造函数都有初始化列表;无论你是否在列表中显式初始化成员,每个成员都要走初始化列表。你没管的内置类型——编译器爱初始不初始;你没管的自定义类型——编译器调用它的默认构造。

类型转换与 explicit

内置类型隐式转为类类型

C++ 支持通过构造函数将内置类型隐式转换为类类型:

class A {
public:
    A(int a1) : _a1(a1) {}
    void Print() { std::cout << _a1 << " " << _a2 << std::endl; }

private:
    int _a1 = 1;
    int _a2 = 2;
};

int main() {
    A aa1 = 1;          // 隐式转换:先用 1 构造临时 A 对象,再用临时对象拷贝构造 aa1
                        // 编译器优化:连续 构造+拷贝构造 → 直接构造
    aa1.Print();        // 输出: 1 2

    const A& aa2 = 1;   // 临时对象绑定到 const 引用,生命周期延长至引用结束

    // C++11 起支持多参数隐式转换
    A aa3 = {2, 2};     // 多参数列表初始化
}

隐式转换是把双刃剑:方便,但也可能在你没注意到的地方悄悄创建临时对象。如果你不希望构造函数被用于隐式转换,在构造函数前加 explicit 关键字:

explicit A(int a1) : _a1(a1) {}

// A aa1 = 1;  // 编译报错:不能隐式转换
A aa1(1);       // 只能显式调用

默认规则:除非你有明确的理由需要隐式转换(比如 std::string(const char*) 这种显然有益的),否则单参数构造函数一律加 explicit。控制隐式路径就是控制代码行为。

类类型间的隐式转换

只要存在对应的构造函数,A 类对象也能隐式转为 B 类:

class B {
public:
    B(const A& a) : _b(a.Get()) {}   // 接受 A 对象的构造
private:
    int _b = 0;
};

B b = aa3;          // aa3 隐式转为 B
const B& rb = aa3;  // 临时 B 对象绑定到 const 引用

static 成员

静态成员变量

static 修饰的成员变量叫静态成员变量(Static Member Variable)。它与普通成员变量的核心区别:

  • 不属于任何单个对象,而是属于整个类,为所有对象共享。
  • 存储在静态区,而非栈或堆。
  • 必须在类外初始化——因为它的生命期不随对象走,不经过构造函数初始化列表。
#include <iostream>

class A {
public:
    A()  { ++_scount; }
    A(const A& t) { ++_scount; }
    ~A() { --_scount; }

    static int GetACount() { return _scount; }

private:
    static int _scount;   // 类内声明,不加 static 就是普通成员了
};

int A::_scount = 0;       // 类外定义+初始化——必须写,否则链接报错

int main() {
    std::cout << A::GetACount() << std::endl;   // 0
    A a1, a2;
    A a3(a1);
    std::cout << A::GetACount() << std::endl;   // 3
    // 也可以通过对象访问,但本质是同一份数据
    std::cout << a1.GetACount() << std::endl;   // 3
}

访问静态成员有两种方式:通过类名A::_scount)或通过对象a1._scount),前提是有访问权限。private 静态成员变量只能通过 public 静态成员函数间接访问。

⚠️ 静态成员变量不能在声明处用缺省值初始化——缺省值是给构造函数初始化列表用的,静态成员不走那套流程。

静态成员函数

static 修饰的成员函数叫静态成员函数(Static Member Function)。核心特征:没有 this 指针

这意味着:

  • 静态成员函数可以访问静态成员变量和其他静态成员函数。
  • 静态成员函数不能访问非静态成员变量——没有 this,根本不知道访问哪个对象的成员。
  • 非静态成员函数可以访问任意的静态成员。

static 的经典应用

面试题级别的案例:计算 1+2+3+...+n,不可用循环和条件判断。利用静态成员变量 + 构造函数:

class Sum {
public:
    Sum() {
        _ret += _i;
        ++_i;
    }
    static int GetRet() { return _ret; }

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

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

class Solution {
public:
    int Sum_Solution(int n) {
        Sum arr[n];            // 调用 n 次 Sum 构造函数,自动累加
        return Sum::GetRet();
    }
};

每构造一个 Sum 对象,_ret 就累加一次 _i_i 自增。n 个对象就是 1+2+…+n。

友元

友元函数

友元(Friend)提供了一种突破访问限定符的方式。在函数声明前加 friend,并将声明放在类内部,该函数就可以访问这个类的 privateprotected 成员。

class B;  // 前置声明——A 的友元声明需要知道 B 的存在

class A {
    friend void func(const A& aa, const B& bb);  // 友元声明——只是声明,不是成员函数
private:
    int _a1 = 1;
    int _a2 = 2;
};

class B {
    friend void func(const A& aa, const B& bb);
private:
    int _b1 = 3;
    int _b2 = 4;
};

void func(const A& aa, const B& bb) {
    std::cout << aa._a1 << std::endl;   // 访问 A 的私有成员——合法,因为 func 是 A 的友元
    std::cout << bb._b1 << std::endl;   // 访问 B 的私有成员——合法,因为 func 是 B 的友元
}

关键约束:

  • 友元声明可以放在类的任何位置,不受访问限定符影响。
  • 友元函数不是成员函数——它只是一个普通的全局函数,被特许访问私有成员。
  • 一个函数可以是多个类的友元。

友元类

一个类可以声明另一个类为友元类:

class A {
    friend class B;       // B 是 A 的友元——B 的所有成员函数都能访问 A 的私有成员
private:
    int _a1 = 1;
    int _a2 = 2;
};

class B {
public:
    void func1(const A& aa) {
        std::cout << aa._a1 << std::endl;   // OK——B 是 A 的友元
    }
    void func2(const A& aa) {
        std::cout << aa._a2 << std::endl;   // OK
    }
};

友元关系的两个重要属性:

属性 含义
单向性 A 声明 B 是朋友,不代表 B 把 A 当朋友。B 能访问 A 的私有成员,A 不能访问 B 的私有成员。
不可传递 A 是 B 的朋友,B 是 C 的朋友,但 A 不是 C 的朋友。

友元增加了耦合度,破坏了封装。不宜多用——把它看作"封装防线上的一个必要缺口",只在重载 <</>> 这类"非用不可"的场景动用。

内部类

一个类定义在另一个类的内部,就叫内部类(Inner Class)。内部类是独立的类,定义在全局也一样——只是它的名字受外部类类域的限制,访问受外部类访问限定符限制。

内部类默认是外部类的友元类——可以访问外部类的私有和静态成员。外部类定义的对象中不包含内部类对象。

#include <iostream>

class A {
private:
    static int _k;
    int _h = 1;

public:
    class B {                           // B 默认是 A 的友元
    public:
        void foo(const A& a) {
            std::cout << _k << std::endl;     // OK——静态成员
            std::cout << a._h << std::endl;   // OK——通过对象访问私有成员
        }
        int _b1;
    };
};

int A::_k = 1;

int main() {
    std::cout << sizeof(A) << std::endl;   // 只含 _h,不含 B 的任何东西——输出 4

    A::B b;                                // 使用外部类域限定
    A aa;
    b.foo(aa);
}

内部类是一种高级封装手段。当 A 类和 B 类紧密关联,B 的存在基本上就是为了服务 A 时,把 B 设计为 A 的 private/protected 内部类,其他地方完全不可见,封装的粒度更细。

之前那道 1+2+...+n 题目,用内部类改写可以避免 Sum 类污染全局命名空间:

class Solution {
    class Sum {
    public:
        Sum() { _ret += _i; ++_i; }
    };
    static int _i;
    static int _ret;
public:
    int Sum_Solution(int n) {
        Sum arr[n];
        return _ret;
    }
};
int Solution::_i = 1;
int Solution::_ret = 0;

匿名对象

类型(实参) 直接创建的对象叫匿名对象(Anonymous Object),不需要给名字:

class A {
public:
    A(int a = 0) : _a(a) {
        std::cout << "A(int a)" << std::endl;
    }
    ~A() {
        std::cout << "~A()" << std::endl;
    }
private:
    int _a;
};

int main() {
    A aa1;           // 有名对象——生命周期到 main 结束
    A();             // 匿名对象——生命周期仅此一行
    A(1);            // 匿名对象——生命周期仅此一行

    A aa2(2);        // 有名对象

    // 匿名对象的典型用途:临时调用一个方法
    Solution().Sum_Solution(10);  // 创建匿名 Solution,调用方法,然后销毁
}

匿名对象的生命周期只在当前一行,下一行自动调用析构销毁。它适合"临时使用一个对象,用完即弃"的场景。

编译器拷贝优化

现代编译器会在不影响正确性的前提下尽可能省略拷贝。这不是 C++ 标准强制规定的,但主流编译器(MSVC、GCC、Clang)都在做。

class A {
public:
    A(int a = 0) : _a1(a)          { std::cout << "A(int a)" << std::endl; }
    A(const A& aa) : _a1(aa._a1)   { std::cout << "A(const A& aa)" << std::endl; }
    A& operator=(const A& aa)      { std::cout << "A& operator=" << std::endl; return *this; }
    ~A()                           { std::cout << "~A()" << std::endl; }
private:
    int _a1 = 1;
};

void f1(A aa) {}                    // 传值——正常情况下会调用拷贝构造
A f2() { A aa; return aa; }         // 传值返回——正常情况下会调用拷贝构造

int main() {
    A aa1;                          // 构造
    f1(aa1);                        // 拷贝构造(无法优化——aa1 是左值)

    f1(1);                          // 隐式类型转换:连续 构造+拷贝构造 → 优化为直接构造
    f1(A(2));                       // 匿名对象:连续 构造+拷贝构造 → 优化为直接构造

    f2();                           // 返回:局部对象 → 可能优化为直接构造(vs2022 debug)
    A aa2 = f2();                   // 接收返回值:可能跨行合并优化(vs2022 debug)
    aa1 = f2();                     // 赋值:无法完全优化(已经存在的对象 + 赋值)

    return 0;
}

优化规律:

场景 原始调用链 优化后
f1(1) 构造临时 + 拷贝构造 直接构造
f1(A(2)) 构造 + 拷贝构造 直接构造
A aa2 = f2() 局部构造 + 拷贝临时 + 拷贝 aa2 可能合并为一次直接构造
aa1 = f2() 局部构造 + 拷贝临时 + 赋值 aa1 只能优化前半段

核心认知:编译器优化的是同一个表达式内连续的"构造+拷贝构造"。越新的编译器越激进(跨行合并),但有没有、怎么优化都没有标准保证。写代码时不要依赖优化——该用引用传参就用引用传参,该用返回值就用返回值,优化只是锦上添花。

💡 背景补充:GCC 用 -fno-elide-constructors 关闭拷贝省略优化,用于验证"不优化时到底调了多少次拷贝"。日常写代码不需要关。

常见误区

误区一:初始化列表顺序与书写顺序无关——但执行顺序与声明顺序有关

初始化列表按声明顺序执行。如果 _a2 声明在 _a1 前面,_a2(_a1) 中访问的 _a1 还未初始化。

误区二:友元是相互的

友元单向且不可传递。A 把 B 当朋友,不代表 B 把 A 当朋友。

误区三:静态成员变量在类内初始化

class A {
    static int _count = 0;   // 编译报错——静态成员变量必须在类外定义
};
int A::_count = 0;           // 正确写法

本节要点

  • 初始化列表是成员变量真正初始化之地。引用、const、无默认构造的类类型——三者必须在此初始化。
  • explicit 禁用隐式类型转换。单参数构造函数默认加 explicit,除非你有意让隐式转换发生。
  • static 成员属于整个类,不属于任何对象。静态成员变量类外初始化,静态成员函数没有 this
  • 友元是封装的例外,不是常态。单向、不可传递。重载 <</>> 时几乎必须用。
  • 内部类默认是外部类的友元。用于"紧密关联、不对外暴露"的类关系。
  • 编译器会优化连续的构造+拷贝构造,但不要依赖优化——正确的设计(如传 const 引用)才是确定性的保障。

📖 参考:《C++ Primer Plus》第10-11章 —— 构造函数初始化列表、友元、运算符重载;《高质量C++/C编程指南》第9章 —— 初始化表 vs 函数体赋值、隐式类型转换;《Effective C++》条款22 —— 封装与访问控制。


*迪亚波罗学编程

更多推荐