C++类和对象完全指南(下
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已经存在)
赋值运算符重载的特点:
- 必须重载为成员函数(这是C++规定的)
- 参数建议写成
const引用,避免传值时的拷贝 - 返回值建议写成引用,支持连续赋值
d1 = d2 = d3 - 要检查自赋值的情况
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 必须使用初始化列表的情况
有三种成员变量必须在初始化列表中初始化,否则编译报错:
- 引用成员变量(引用必须在定义时初始化)
- const成员变量(const必须在定义时初始化)
- 没有默认构造的类类型成员(编译器无法自动初始化)
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(); // 对象.静态成员(不推荐,容易误解为普通成员)
静态成员也受访问限定符的限制。如果 _scount 是 private 的,外部就不能直接 A::_scount,只能通过公有静态成员函数 GetACount() 来访问。
静态成员变量不能在声明位置给缺省值,因为缺省值是给初始化列表用的,而静态成员变量不走初始化列表。
4.4 实战:求1+2+3+…+n
牛客网上一道经典题,要求不能用 if、while、for、?: 等关键字。利用静态成员和变长数组可以巧妙解决:
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();
}
};
创建 n 个 Sum 对象,每个对象的构造函数都会执行 _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 内部类的意义
内部类本质上是一种封装。当两个类紧密关联,其中一个类主要是给另一个类用的,就可以设计为内部类。如果放在 private 或 protected 位置,外部就完全用不了,实现了真正的"专属"。
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 | 编译器优化 | 连续构造+拷贝→直接构造;各编译器优化程度不同 |
十、总结与实践建议
学完类和对象,回头看这些知识点,核心其实就是几件事:
- 封装:用类把数据和方法绑定,用访问限定符控制访问权限
- 资源管理:构造函数初始化,析构函数清理,拷贝构造和赋值重载处理深拷贝
- 运算符重载:让自定义类型支持运算符,像内置类型一样自然地使用
- static和友元:解决"类级别共享数据"和"需要突破封装"的特殊需求
几个实践建议:
- 能用
const的成员函数都加const,这是好习惯 - 单参数构造函数加
explicit,避免意外的隐式转换 - 优先使用初始化列表,尤其是有引用、const、无默认构造成员时
- 判断是否需要自己写拷贝构造/赋值重载:看有没有显式写析构释放资源
- 友元能不用就不用,确实需要时(比如
<</>>)再使用
类和对象是C++的基石,后面学继承、多态、模板都建立在这个基础之上。把这些概念吃透,后面的内容会轻松很多。
更多推荐



所有评论(0)