C++类和对象完全指南(下)——运算符重载与进阶特性

前言

上篇讲了类的基础概念、对象模型、构造函数、析构函数和拷贝构造。这篇继续往下走,覆盖运算符重载、初始化列表、类型转换、static成员、友元、内部类、匿名对象以及编译器的拷贝优化。

这些内容是C++类和对象中更进阶的部分,也是实际开发中经常用到的特性。

一、运算符重载

1.1 为什么需要运算符重载

C++允许自定义类型使用运算符(+-==<< 等),但前提是你必须告诉编译器这个运算符对于你的类型意味着什么。这就是运算符重载。

比如 Date 类,判断两个日期是否相等,没有运算符重载的话你得写一个 IsEqual 函数:

// 没有运算符重载,只能这样写
if (IsEqual(d1, d2)) { ... }

// 有了运算符重载,可以这样写
if (d1 == d2) { ... }

运算符重载本质上是一个具有特殊名字的函数,名字由 operator 和运算符组成,比如 operator==operator+

1.2 运算符重载的规则

  • 参数个数和运算对象数量一样多(一元运算符一个参数,二元运算符两个参数)
  • 如果重载为成员函数,第一个参数默认是 this 指针,所以参数比运算对象少一个
  • 重载后优先级和结合性与内置运算符一致
  • 不能创造新的运算符(比如 operator@
  • 以下5个运算符不能重载(常考选择题):.*::sizeof?:.
  • 至少有一个类类型参数,不能改变内置类型的含义

1.3 全局重载 vs 成员函数重载

运算符重载可以写成全局函数,也可以写成成员函数。写成全局函数的问题是:访问不了私有成员。有几种解决办法:把成员放 public、提供 get 函数、用友元函数、或者直接重载为成员函数。

// 全局重载
bool operator==(const Date& d1, const Date& d2)
{
    return d1._year == d2._year
        && d1._month == d2._month
        && d1._day == d2._day;
}

// 成员函数重载(this指针是第一个参数)
bool Date::operator==(const Date& d)
{
    return _year == d._year
        && _month == d._month
        && _day == d._day;
}

调用方式有两种,效果一样:

d1 == d2;              // 编译器转换成 d1.operator==(d2)
d1.operator==(d2);     // 显式调用

1.4 前置++和后置++

++ 运算符有前置和后置两种形式,函数名都是 operator++,怎么区分?

C++的规定是:后置 ++ 增加一个 int 形参,和前置 ++ 构成重载。这个 int 参数没有实际用途,纯粹是为了区分。

// 前置++:返回引用(因为对象本身被修改了,返回它的引用)
Date& Date::operator++()
{
    *this += 1;
    return *this;
}

// 后置++:返回值(因为返回的是修改前的副本)
Date Date::operator++(int)
{
    Date tmp(*this);  // 保存当前状态
    *this += 1;       // 修改自身
    return tmp;       // 返回修改前的副本
}

调用时:

++d1;    // 编译器转换成 d1.operator++()
d1++;    // 编译器转换成 d1.operator++(0)

1.5 流插入 << 和流提取 >>

这两个运算符比较特殊。如果重载为成员函数,this 指针会抢占第一个参数位置,调用时变成 d1 << cout,反了。

所以 <<>> 必须重载为全局函数,把 ostream/istream 放到第一个参数:

// 流插入
ostream& operator<<(ostream& out, const Date& d)
{
    out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
    return out;  // 支持连续输出:cout << d1 << d2
}

// 流提取
istream& operator>>(istream& in, Date& d)
{
    cout << "请依次输入年月日:>";
    in >> d._year >> d._month >> d._day;
    if (!d.CheckDate())
    {
        cout << "日期非法" << endl;
    }
    return in;  // 支持连续输入:cin >> d1 >> d2
}

但全局函数访问不了 Date 的私有成员,所以需要在 Date 类里声明友元:

class Date
{
    // 友元声明
    friend ostream& operator<<(ostream& out, const Date& d);
    friend istream& operator>>(istream& in, Date& d);
    // ...
};

1.6 const成员函数

const 放在成员函数参数列表后面,就变成了 const 成员函数。const 实际修饰的是隐含的 this 指针:

// 普通成员函数:this 的类型是 Date* const
void Print()
{
    cout << _year << "-" << _month << "-" << _day << endl;
}

// const成员函数:this 的类型是 const Date* const
void Print() const
{
    cout << _year << "-" << _month << "-" << _day << endl;
}

const 成员函数承诺不修改任何成员变量。它的实际用途是:const 对象只能调用 const 成员函数,非 const 对象两者都能调用

const Date d1(2024, 7, 5);
d1.Print();       // OK,调用const版本
// d1 += 100;     // 编译报错,const对象不能调非const成员函数

Date d2(2024, 7, 5);
d2.Print();       // OK,非const对象也能调const成员函数(权限缩小)
d2 += 100;        // OK

这是一个权限的问题:const 对象的指针是 const Date*,不能传给需要 Date* 的普通成员函数,但可以传给需要 const Date*const 成员函数。非 const 对象反过来也兼容——权限缩小是安全的。

实际开发中,只要成员函数不修改成员变量,都应该加 const。这是一个好习惯。

1.7 赋值运算符重载

赋值运算符重载和拷贝构造经常被搞混,它们的区别是:

  • 拷贝构造:用一个已有对象初始化一个新对象
  • 赋值重载:两个已存在的对象之间拷贝
Date d1(2024, 7, 5);
Date d2 = d1;   // 拷贝构造(d2还不存在)
Date d3;
d3 = d1;        // 赋值重载(d3已经存在)

赋值运算符重载的特点:

  1. 必须重载为成员函数(这是C++规定的)
  2. 参数建议写成 const 引用,避免传值时的拷贝
  3. 返回值建议写成引用,支持连续赋值 d1 = d2 = d3
  4. 要检查自赋值的情况
Date& Date::operator=(const Date& d)
{
    if (this != &d)  // 检查自己给自己赋值
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
    return *this;    // 返回自身引用,支持连续赋值
}

编译器自动生成的赋值重载和拷贝构造行为一致:内置类型做浅拷贝,自定义类型调它的赋值重载。所以判断标准也一样——如果类有动态资源,必须自己实现深拷贝版本的赋值重载

1.8 取地址运算符重载

最后两个默认成员函数——普通取地址和 const 取地址重载,一般不需要自己写,编译器生成的就够用。只有在特殊场景下(比如不想让别人拿到对象的真实地址)才需要自定义:

Date* operator&()
{
    return this;
    // return nullptr;  // 返回假地址
}

const Date* operator&() const
{
    return this;
    // return nullptr;
}

了解即可,实际开发中几乎不会自己实现。

二、初始化列表

2.1 初始化列表的语法

构造函数初始化成员变量,之前我们都是在函数体里赋值:

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

C++还提供了另一种初始化方式——初始化列表,在构造函数参数列表后面加冒号,列出每个成员及其初始值:

Date(int year, int month, int day)
    : _year(year)
    , _month(month)
    , _day(day)
{
    // 函数体
}

2.2 必须使用初始化列表的情况

有三种成员变量必须在初始化列表中初始化,否则编译报错:

  1. 引用成员变量(引用必须在定义时初始化)
  2. const成员变量(const必须在定义时初始化)
  3. 没有默认构造的类类型成员(编译器无法自动初始化)
class Time
{
public:
    Time(int hour) : _hour(hour) {}  // 没有默认构造
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)        // Time没有默认构造,必须在初始化列表初始化
        , _ref(x)       // 引用必须在初始化列表初始化
        , _n(1)         // const必须在初始化列表初始化
    {}
private:
    int _year;
    int _month;
    int _day;
    Time _t;            // 没有默认构造
    int& _ref;          // 引用
    const int _n;       // const
};

2.3 初始化列表的执行顺序

成员变量按照在类中声明的顺序初始化,跟初始化列表中的书写顺序无关。这是一个非常容易出错的点。

class A
{
public:
    A(int a)
        : _a1(a)     // 第二个初始化
        , _a2(_a1)   // 第一个初始化,此时_a1还是未初始化的随机值!
    {}

    void Print() { cout << _a1 << " " << _a2 << endl; }
private:
    int _a2 = 2;   // 声明在前,先初始化
    int _a1 = 2;   // 声明在后,后初始化
};

int main()
{
    A aa(1);
    aa.Print();  // 输出:1 2
    // _a2先初始化,此时_a1还没初始化,用缺省值2
    // _a1后初始化,值为1
}

初始化 _a2_a1 还没初始化,所以 _a2(_a1) 拿到的是随机值,但因为有缺省值 = 2,所以 _a2 用了缺省值 2。然后 _a1 被初始化为 1。最终输出 1 2

建议:声明顺序和初始化列表顺序保持一致,避免这种混淆

2.4 C++11成员变量缺省值

C++11允许在成员变量声明时给缺省值:

class Date
{
public:
    Date()
        : _month(2)  // 只显式初始化_month
    {}
private:
    int _year = 1;    // 缺省值
    int _month = 1;   // 缺省值,但初始化列表里写了2,用2
    int _day;         // 没有缺省值,也没在初始化列表初始化,内置类型不确定
};

注意:声明位置的 = 1 不是初始化,是给初始化列表的缺省值。如果初始化列表没有显式初始化该成员,就用这个缺省值。

2.5 尽量使用初始化列表

不管你写不写初始化列表,每个成员变量都会走初始化列表。如果你不写,编译器也会用缺省值或默认构造来初始化。所以建议尽量使用初始化列表,效率更高(直接初始化 vs 先默认初始化再赋值)。

三、explicit与类型转换

3.1 隐式类型转换

C++支持内置类型隐式转换为类类型,只要类有对应的构造函数:

class A
{
public:
    A(int a1) : _a1(a1) {}
    A(int a1, int a2) : _a1(a1), _a2(a2) {}
private:
    int _a1 = 1;
    int _a2 = 2;
};

int main()
{
    A aa1 = 1;        // int隐式转换为A:先用1构造临时对象,再拷贝构造aa1
                       // 编译器优化为直接构造
    const A& aa2 = 1; // 同上,引用绑定临时对象
    A aa3 = {2, 2};   // C++11支持多参数隐式转换
    return 0;
}

A aa1 = 1 的过程是:先用 1 构造一个 A 的临时对象,再用这个临时对象拷贝构造 aa1。但编译器通常会优化成直接构造,省掉中间的拷贝。

类类型之间也可以隐式转换,原理类似。

3.2 explicit关键字

如果你不想支持隐式类型转换,在构造函数前加 explicit

class A
{
public:
    explicit A(int a1) : _a1(a1) {}  // 禁止隐式转换
    // explicit A(int a1, int a2) : _a1(a1), _a2(a2) {}
private:
    int _a1;
    int _a2;
};

int main()
{
    // A aa1 = 1;     // 编译报错!explicit禁止隐式转换
    A aa2(1);         // OK,直接构造
    return 0;
}

对于单参数构造函数,建议加 explicit,防止意外的隐式转换带来bug。多参数构造函数看情况,C++11之后 {2, 2} 这种列表初始化也会触发隐式转换。

四、static成员

4.1 静态成员变量

static 修饰的成员变量叫静态成员变量

  • 为所有对象共享,不属于某个具体对象
  • 不存在对象中,存放在静态区
  • 必须在类外初始化
class A
{
public:
    A() { ++_scount; }
    A(const A& t) { ++_scount; }
    ~A() { --_scount; }

    static int GetACount() { return _scount; }
private:
    static int _scount;  // 类内声明
};

int A::_scount = 0;      // 类外初始化

int main()
{
    cout << A::GetACount() << endl;  // 0
    A a1, a2;
    A a3(a1);
    cout << A::GetACount() << endl;  // 3
    cout << a1.GetACount() << endl;  // 3(对象也能调静态成员函数)
    return 0;
}

静态成员变量可以用来统计创建了多少个对象——构造函数里 ++,拷贝构造里 ++,析构函数里 --

4.2 静态成员函数

static 修饰的成员函数叫静态成员函数

  • 没有 this 指针
  • 只能访问静态成员,不能访问非静态成员(因为没有 this 就找不到对象)
  • 非静态成员函数可以访问任意静态成员
class A
{
public:
    static int GetACount()  // 没有 this 指针
    {
        return _scount;     // OK,静态成员
        // return _a;       // 编译报错!没有this指针,不知道_a是哪个对象的
    }
private:
    static int _scount;
    int _a;
};

4.3 访问方式

静态成员突破类域就可以访问:

A::GetACount();    // 类名::静态成员
a1.GetACount();    // 对象.静态成员(不推荐,容易误解为普通成员)

静态成员也受访问限定符的限制。如果 _scountprivate 的,外部就不能直接 A::_scount,只能通过公有静态成员函数 GetACount() 来访问。

静态成员变量不能在声明位置给缺省值,因为缺省值是给初始化列表用的,而静态成员变量不走初始化列表。

4.4 实战:求1+2+3+…+n

牛客网上一道经典题,要求不能用 ifwhilefor?: 等关键字。利用静态成员和变长数组可以巧妙解决:

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();
    }
};

创建 nSum 对象,每个对象的构造函数都会执行 _ret += _i; ++_i;,相当于把 1 到 n 加了一遍。

五、友元

5.1 友元函数

友元提供了一种突破封装的方式。在类里声明一个函数为友元,这个函数就能访问类的私有成员:

class 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)
{
    cout << aa._a1 << endl;  // OK,友元可以访问私有成员
    cout << bb._b1 << endl;  // OK
}

友元函数不是类的成员函数,它只是一个声明,告诉编译器"这个函数是我的朋友,可以访问我的私有成员"。

友元声明可以放在类定义的任何位置,不受访问限定符限制。一个函数可以是多个类的友元。

5.2 友元类

如果一个类的所有成员函数都需要访问另一个类的私有成员,可以把整个类声明为友元:

class A
{
    friend class B;  // B是A的友元类
private:
    int _a1 = 1;
    int _a2 = 2;
};

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

关于友元类有两个重要特性:

  • 友元关系是单向的:A是B的友元,不代表B是A的友元
  • 友元关系不能传递:A是B的友元,B是C的友元,不代表A是C的友元

友元虽然方便,但会增加耦合度,破坏封装,所以不宜多用。只在确实需要的时候才用(比如 operator<<operator>>)。

六、内部类

6.1 什么是内部类

如果一个类定义在另一个类的内部,就叫内部类

class A
{
public:
    class B  // 内部类
    {
    public:
        void foo(const A& a)
        {
            cout << a._h << endl;  // OK,B默认是A的友元
        }
    };
private:
    int _h = 1;
    static int _k;
};

int A::_k = 1;

内部类的特点:

  • 它是一个独立的类,外部类对象中不包含内部类(sizeof(A) 不包含 B 的大小)
  • 内部类默认是外部类的友元,可以访问外部类的私有成员
  • 内部类受外部类的类域和访问限定符限制

6.2 内部类的意义

内部类本质上是一种封装。当两个类紧密关联,其中一个类主要是给另一个类用的,就可以设计为内部类。如果放在 privateprotected 位置,外部就完全用不了,实现了真正的"专属"。

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;
    }
};

和前面 static 成员版本的 1+2+...+n 相比,内部类版本把 Sum 类封装在 Solution 内部,外部看不到也用不了,更干净。

七、匿名对象

7.1 匿名对象的定义与使用

类型(实参) 定义出来的对象叫匿名对象,没有名字:

A aa1;     // 有名对象
A();       // 匿名对象
A(1);      // 带参的匿名对象

匿名对象最大的特点是:生命周期只有当前行,下一行就自动析构了。

A(1);       // 这行结束就析构了
cout << "匿名对象已销毁" << endl;

7.2 匿名对象的实用场景

当你只需要临时用一下某个对象,不需要它长期存在时,匿名对象就很方便:

Solution().Sum_Solution(10);  // 创建临时Solution对象,调完就销毁

还有一个容易踩的坑:A aa1(); 不是定义匿名对象,编译器会把它当成函数声明。但 A(1) 是匿名对象,编译器能区分。

八、编译器的拷贝优化

8.1 连续构造+拷贝构造的优化

现代编译器会在不影响正确性的前提下,减少不必要的拷贝。最常见的优化是:一个表达式中的连续构造+拷贝构造,优化为直接构造

void f1(A aa) {}

int main()
{
    // 传值传参
    A aa1;
    f1(aa1);       // 构造aa1 + 拷贝构造给形参(两次)

    // 隐式类型转换
    f1(1);         // 用1构造临时对象 + 拷贝构造给形参 → 优化为直接构造(一次)

    // 匿名对象传参
    f1(A(2));      // 构造匿名对象 + 拷贝构造给形参 → 优化为直接构造(一次)
    return 0;
}

8.2 传值返回的优化

A f2()
{
    A aa;
    return aa;
}

int main()
{
    f2();              // 可能优化为:直接构造(最激进的编译器)

    A aa2 = f2();      // 可能优化为:直接构造aa2(跨行合并优化)

    aa1 = f2();        // 构造局部对象 + 拷贝构造临时对象 + 赋值
                       // 连续的构造+拷贝可以优化,但中间有赋值就不好优化了
    return 0;
}

不同编译器的优化程度不同。VS2019 Debug 模式下优化较少,VS2022 Debug 模式下会更激进。Linux 下可以用 g++ -fno-elide-constructors 关闭优化来观察未优化的行为。

了解这些优化有助于理解代码的实际执行过程,但写代码时不用刻意去迎合编译器的优化——正常写就行,编译器会帮你处理。

九、本篇知识点速查表

序号 知识点 要点
1 运算符重载 operator 关键字;5个不可重载:.* :: sizeof ?: .;至少一个类类型参数
2 前置/后置++ 后置++增加 int 形参区分;前置返回引用,后置返回值
3 流插入/提取 必须重载为全局函数;需要友元声明
4 const成员函数 修饰this为 const T* const;const对象只能调const成员函数
5 赋值运算符重载 必须是成员函数;检查自赋值;引用返回支持连续赋值
6 初始化列表 引用/const/无默认构造成员必须用初始化列表;按声明顺序初始化
7 C++11缺省值 声明位置给缺省值,给初始化列表用
8 explicit 禁止隐式类型转换;单参数构造建议加
9 static成员变量 类外初始化;所有对象共享;存静态区
10 static成员函数 无this指针;只能访问静态成员
11 友元函数 突破封装;不是成员函数;可访问私有成员
12 友元类 单向、不可传递;增加耦合度不宜多用
13 内部类 独立类;默认是外部类友元;实现封装
14 匿名对象 生命周期仅当前行;适用临时使用场景
15 编译器优化 连续构造+拷贝→直接构造;各编译器优化程度不同

十、总结与实践建议

学完类和对象,回头看这些知识点,核心其实就是几件事:

  1. 封装:用类把数据和方法绑定,用访问限定符控制访问权限
  2. 资源管理:构造函数初始化,析构函数清理,拷贝构造和赋值重载处理深拷贝
  3. 运算符重载:让自定义类型支持运算符,像内置类型一样自然地使用
  4. static和友元:解决"类级别共享数据"和"需要突破封装"的特殊需求

几个实践建议:

  • 能用 const 的成员函数都加 const,这是好习惯
  • 单参数构造函数加 explicit,避免意外的隐式转换
  • 优先使用初始化列表,尤其是有引用、const、无默认构造成员时
  • 判断是否需要自己写拷贝构造/赋值重载:看有没有显式写析构释放资源
  • 友元能不用就不用,确实需要时(比如 <</>>)再使用

类和对象是C++的基石,后面学继承、多态、模板都建立在这个基础之上。把这些概念吃透,后面的内容会轻松很多。

更多推荐