摘要 :类和对象把数据与操作数据的函数放进同一个类型,并用访问限定符控制外部能改什么。本文从
Date结构体加一组全局函数写起,讲类的定义、访问限定符、类域、实例化与对象大小、this指针;再用Stack和Date两个类串起构造、析构、拷贝构造、赋值重载,以及Date的比较和算术运算符;最后补初始化列表、explicit、static成员、友元、内部类、匿名对象和拷贝省略。贯穿全篇的线索是对象什么时候构造、什么时候拷贝、什么时候赋值、什么时候析构。
文章目录
- [1. 从结构体到类](#1. 从结构体到类)
-
- [1.1 访问限定符与封装](#1.1 访问限定符与封装)
- [1.2 类域](#1.2 类域)
- [1.3 C++ 的 struct 也是类](#1.3 C++ 的 struct 也是类)
- [2. 实例化与对象大小](#2. 实例化与对象大小)
-
- [2.1 类不占空间,对象才占](#2.1 类不占空间,对象才占)
- [2.2 对象里只存成员变量](#2.2 对象里只存成员变量)
- [2.3 内存对齐与空类](#2.3 内存对齐与空类)
- [3. this 指针](#3. this 指针)
-
- [3.1 成员函数怎么知道操作哪个对象](#3.1 成员函数怎么知道操作哪个对象)
- [3.2 空指针调用成员函数的两种结果](#3.2 空指针调用成员函数的两种结果)
- [4. 封装:同一个 Stack 的两种写法](#4. 封装:同一个 Stack 的两种写法)
-
- [4.1 C 的写法](#4.1 C 的写法)
- [4.2 C++ 的写法](#4.2 C++ 的写法)
- [4.3 两种写法对照](#4.3 两种写法对照)
- [5. 构造函数与析构函数](#5. 构造函数与析构函数)
-
- [5.1 六个默认成员函数](#5.1 六个默认成员函数)
- [5.2 构造函数](#5.2 构造函数)
- [5.3 析构函数](#5.3 析构函数)
- [6. 拷贝构造与赋值重载](#6. 拷贝构造与赋值重载)
-
- [6.1 拷贝构造函数](#6.1 拷贝构造函数)
- [6.2 什么时候调用拷贝构造](#6.2 什么时候调用拷贝构造)
- [6.3 赋值运算符重载](#6.3 赋值运算符重载)
- [7. 用 Date 串起运算符重载](#7. 用 Date 串起运算符重载)
-
- [7.1 const 成员函数](#7.1 const 成员函数)
- [7.2 比较运算符](#7.2 比较运算符)
- [7.3 日期加减与前后置 ++](#7.3 日期加减与前后置 ++)
- [7.4 取地址运算符重载](#7.4 取地址运算符重载)
- [7.5 完整实现与测试](#7.5 完整实现与测试)
- [8. 初始化列表与 explicit](#8. 初始化列表与 explicit)
-
- [8.1 初始化列表](#8.1 初始化列表)
- [8.2 声明处缺省值与初始化顺序](#8.2 声明处缺省值与初始化顺序)
- [8.3 explicit 与隐式类型转换](#8.3 explicit 与隐式类型转换)
- [9. static 成员与友元](#9. static 成员与友元)
-
- [9.1 静态成员](#9.1 静态成员)
- [9.2 友元函数](#9.2 友元函数)
- [9.3 友元类](#9.3 友元类)
- [10. 内部类与匿名对象](#10. 内部类与匿名对象)
-
- [10.1 内部类](#10.1 内部类)
- [10.2 匿名对象](#10.2 匿名对象)
- [11. 拷贝省略](#11. 拷贝省略)
- [12. 小结](#12. 小结)
1. 从结构体到类
写一个日期结构体,再配几个函数,是 C 里常见的写法:
c
#include <stdio.h>
typedef struct Date
{
int year;
int month;
int day;
} Date;
void DateInit(Date* d, int year, int month, int day)
{
d->year = year;
d->month = month;
d->day = day;
}
void DatePrint(const Date* d)
{
printf("%d-%d-%d\n", d->year, d->month, d->day);
}
int main(void)
{
Date d;
DateInit(&d, 2024, 3, 31);
DatePrint(&d);
d.month = 13;
DatePrint(&d);
return 0;
}
输出:
text
2024-3-31
2024-13-31
第二行是 2024-13-31:DateInit 挡不住后面的 d.month = 13。数据放在结构体里,函数放在外面,谁能改、什么时候改,只能靠调用者记住。
C++ 的类把两者放进同一个类型:
cpp
#include <iostream>
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
d.Init(2024, 3, 31);
d.Print();
return 0;
}
class 后面的 Date 是类名,{} 里是类体,类定义结束后的分号不能省。类体里的变量叫成员变量,函数叫成员函数。
成员变量名前的 _ 只是约定,用来和形参区分,不是语法要求;写成 year_ 或 m_year 也可以,跟着项目规范走。
定义在类体内的成员函数默认是内联的。Date 的两个函数也能放到类外定义,1.2 节会看到写法。
1.1 访问限定符与封装
public 成员在类外可以访问,private 和 protected 不行。访问权限从限定符出现的位置开始,到下一个限定符或类结束的 } 为止。
| 限定符 | 类内访问 | 类外访问 | 用途 |
|---|---|---|---|
public |
可以 | 可以 | 对外接口 |
protected |
可以 | 不可以 | 继承时才有区别 |
private |
可以 | 不可以 | 成员变量默认放这里 |
protected 目前和 private 表现一样,继承时才有区别。class 的成员默认 private,struct 的成员默认 public。
封装(encapsulation)不是把成员藏起来就完事,而是让外部只能通过公开接口修改数据。Date 的 _month 是 private,外部写 d._month = 13 会编译报错,只能调用 Init。
访问控制本身不检查日期是否合法。它的作用是把修改集中到 Init 一个入口;要挡非法日期,检查逻辑写在这里即可。C 版本没有这个入口,任何拿到 Date* 的代码都能直接改字段。
1.2 类域
类定义了一个新的作用域,成员都在类域里。类外定义成员函数时必须写 Date:::
cpp
#include <iostream>
class Date
{
public:
void Init(int year, int month, int day);
void Print();
private:
int _year, _month, _day;
};
void Date::Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Date::Print()
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
int main()
{
Date d;
d.Init(2024, 3, 31);
d.Print();
return 0;
}
如果写成 void Init(...) 而不带 Date::,编译器会把它当成全局函数,函数体里找不到 _year 等成员,直接报错。
指定 Date:: 后,编译器才知道这是成员函数;当前作用域找不到的名字会继续到类域里查找。类域只影响编译期的名字查找,不影响成员变量的生命周期。
1.3 C++ 的 struct 也是类
C++ 保留 C 的 struct,同时把它升级成类:可以在里面定义函数;struct 名本身就能当类型名,不再需要 typedef;C 的用法仍然兼容。
cpp
struct ListNode
{
void Init(int x)
{
val = x;
next = nullptr;
}
ListNode* next;
int val;
};
int main()
{
ListNode node;
node.Init(1);
return 0;
}
C 版本通常写成 typedef struct ListNode { ... } LTNode;,用的时候写 LTNode;C++ 直接写 ListNode。struct 的成员默认 public,class 的成员默认 private,这是两者在成员访问上的默认差别。
定义类一般用 class;只是把几个字段聚合在一起、不需要访问控制时,可以用 struct。
2. 实例化与对象大小
类只是类型,像一张图纸。声明 Date 不会给成员变量分配空间;用类创建对象的过程叫实例化(instantiation)。
类中的成员变量只是声明,对象创建时才分配空间。每个对象有自己独立的一份成员变量;d1.Init(...) 不会改到 d2。
2.1 类不占空间,对象才占
Date d1; 和 Date d2; 各创建一个对象,d1 与 d2 的 _year、_month、_day 分别独立。
一个类可以实例化出很多对象。同一张图纸可以盖很多栋房子,每个对象的数据互不影响。
2.2 对象里只存成员变量
对象只存非静态成员变量。成员函数编译成一段指令,放在代码段,所有对象共用一份。

普通成员函数的地址在编译链接阶段就能确定,不需要每个对象存一份函数指针。sizeof(Date) 是 12(三个 int),成员函数不计入对象大小。
sizeof 是编译期运算,写 sizeof(Date) 或 sizeof d1 都不会在运行时遍历对象。
2.3 内存对齐与空类
对象大小遵循内存对齐(memory alignment)规则:第一个成员放在偏移 0;其他成员放到对齐数的整数倍;对象总大小是最大对齐数的整数倍。
对齐数取 min(编译器默认对齐数, 成员大小)。VS2026 默认对齐数是 8,int 是 4,char 是 1。
cpp
#include <iostream>
class A
{
public:
void Print() { std::cout << _ch << std::endl; }
private:
char _ch;
int _i;
};
class B
{
public:
void Print() { std::cout << "B" << std::endl; }
};
class C {};
int main()
{
std::cout << sizeof(A) << std::endl;
std::cout << sizeof(B) << std::endl;
std::cout << sizeof(C) << std::endl;
return 0;
}
输出:
text
8
1
1
A 的 _ch 在偏移 0,后面补 3 字节,_i 在偏移 4 到 7,总大小 8。B 和 C 没有成员变量,sizeof 仍是 1。
空类占 1 字节是占位:如果一个字节都不给,就无法表示"对象存在过",也没法区分两个不同对象的地址。
成员顺序也会改变大小:
cpp
class E1
{
char _a;
int _i;
char _b;
};
class E2
{
char _a;
char _b;
int _i;
};
E1 的排布是 _a 在 0,补 3 字节,_i 在 4 到 7,_b 在 8,再补 3 字节,总共 12。E2 是 _a 在 0、_b 在 1,补 2 字节,_i 在 4 到 7,总共 8。
| 类 | 偏移排布 | sizeof |
|---|---|---|
E1 |
_a@0,补 3;_i@4--7;_b@8,补 3 |
12 |
E2 |
_a@0、_b@1,补 2;_i@4--7 |
8 |
同样三个成员,换个顺序差 4 字节。把占用小的成员集中放在一起通常更省空间,但不要为了几字节打乱可读性。
3. this 指针
3.1 成员函数怎么知道操作哪个对象
d1.Init(...) 和 d2.Init(...) 调的是同一段代码。函数体里没有写对象名,它怎么知道改的是 d1 还是 d2?
C++ 给每个非静态成员函数加一个隐含参数 this,类型是 Date* const。Init 编译后的真实原型是:
cpp
// 编译器内部的等价形式,不能直接写
void Init(Date* const this, int year, int month, int day);
this 指向调用该函数的对象。函数体里的 _year = year 等价于 this->_year = year。
cpp
class Date
{
public:
void Init(int year, int month, int day)
{
this->_year = year;
_month = month;
this->_day = day;
}
private:
int _year, _month, _day;
};
this 不能在形参列表里显式写,编译器已经加了;可以在函数体里用。它的类型是 Date* const,指针本身是常量,this = nullptr; 会编译报错。
this 是成员函数的隐含形参,随函数调用保存在栈上或寄存器里,具体位置由编译器决定,不在对象内部。

| 写法 | 实际含义 |
|---|---|
_year |
this->_year |
d1.Init(2024, 3, 31) |
Date::Init(&d1, 2024, 3, 31) |
d2.Init(2023, 7, 5) |
Date::Init(&d2, 2023, 7, 5) |
3.2 空指针调用成员函数的两种结果
空指针调用成员函数有两种结果,取决于函数有没有访问成员变量。
Print 的实现 |
结果 | 原因 |
|---|---|---|
| 只输出字符串,不访问成员 | 多数实现能运行,但属于未定义行为 | 调用成员函数不需要解引用对象;this 是空指针但没被使用 |
访问 _a |
运行崩溃 | _a 即 this->_a,对空指针解引用 |
只输出字符串、不访问成员的函数,多数实现能跑通;但 C++ 标准把这种调用定为未定义行为(undefined behavior,UB),不要依赖。
一旦函数里读写 _a,_a 就是 this->_a,对空指针解引用,程序会崩溃。
4. 封装:同一个 Stack 的两种写法
4.1 C 的写法
C 版 Stack 把数据放在结构体里,函数单独定义,每个函数显式接收 ST*:
c
typedef struct Stack
{
int* a;
int top;
int capacity;
} ST;
调用时要写 STInit(&s)、STPush(&s, 1)、STTop(&s)、STDestroy(&s),每一步都把 &s 传来传去。
数据、函数、参数三处都要对上;任何拿到 ST* 的代码都能改 s.top 或 s.capacity。初始化、扩容、释放的规则靠调用者记住。
4.2 C++ 的写法
把数据和接口放进类,成员变量设为 private,只暴露 Push、Pop、Top、Empty、Init、Destroy:
cpp
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <iostream>
class Stack
{
public:
void Init(int n = 4)
{
assert(n > 0);
_a = (int*)malloc(sizeof(int) * n);
if (_a == nullptr)
{
perror("malloc 申请空间失败");
_capacity = _top = 0;
return;
}
_capacity = n;
_top = 0;
}
void Push(int x)
{
if (_top == _capacity)
{
size_t new_capacity = _capacity == 0 ? 4 : _capacity * 2;
int* tmp = (int*)realloc(_a, sizeof(int) * new_capacity);
if (tmp == nullptr)
{
perror("realloc 失败");
return;
}
_a = tmp;
_capacity = new_capacity;
}
_a[_top++] = x;
}
void Pop()
{
assert(_top > 0);
--_top;
}
int Top()
{
assert(_top > 0);
return _a[_top - 1];
}
bool Empty()
{
return _top == 0;
}
void Destroy()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
size_t _capacity;
size_t _top;
};
int main()
{
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
while (!s.Empty())
{
std::cout << s.Top() << std::endl;
s.Pop();
}
s.Destroy();
return 0;
}
输出:
text
3
2
1
_a、_capacity、_top 是私有的,外部只能通过接口操作。Init 统一申请空间,Destroy 统一释放;调用时不用再传 ST*,this 隐式传递。
Init 只应调用一次;重复调用会覆盖 _a,旧空间就泄漏了。构造函数版本没有这个问题,因为对象只构造一次。
不过这份代码仍要手动调用 Init 和 Destroy。下一节的构造函数和析构函数会把这两步也自动化。
4.3 两种写法对照
| 操作 | C 版 | C++ 版 |
|---|---|---|
| 初始化 | STInit(&s); |
s.Init(); |
| 入栈 | STPush(&s, 1); |
s.Push(1); |
| 取栈顶 | STTop(&s); |
s.Top(); |
| 出栈 | STPop(&s); |
s.Pop(); |
| 判空 | STEmpty(&s); |
s.Empty(); |
| 释放 | STDestroy(&s); |
s.Destroy(); |
| 对比项 | C 版 | C++ 版 |
|---|---|---|
| 数据与函数 | 分开 | 同一个类 |
| 访问控制 | 无 | public / private |
| 参数传递 | 显式传 ST* |
this 隐式传递 |
| 类型名 | 需要 typedef |
类名直接当类型名 |
| 初始化 | 手动 STInit |
仍需手动 Init |
| 清理 | 手动 STDestroy |
仍需手动 Destroy |
| 接口 | 时间复杂度 | 说明 |
|---|---|---|
Push |
均摊 O(1) | 扩容时复制 n 个元素,均摊到每次插入 |
Pop / Top / Empty |
O(1) | 只改下标或读一个元素 |
Init / Destroy |
O(1) | 一次 malloc 或 free |
Push 的最坏情况是扩容,复杂度 O(n);连续插入 n 个元素总代价 O(n),所以均摊 O(1)。
5. 构造函数与析构函数
5.1 六个默认成员函数
不写时由编译器自动生成的成员函数叫默认成员函数(default member function)。一个类默认有 6 个,前 4 个最常用:
| 函数 | 作用 | 调用时机 |
|---|---|---|
| 构造函数 | 初始化对象 | 对象创建时 |
| 析构函数 | 清理资源 | 对象生命周期结束时 |
| 拷贝构造函数 | 用同类型对象初始化新对象 | 拷贝构造时 |
赋值运算符重载 operator= |
两个已存在对象之间赋值 | d1 = d2 时 |
operator& |
普通对象取地址 | &d1 时 |
operator&() const |
const 对象取地址 | &d2 时(d2 为 const) |
C++11 还会增加移动构造和移动赋值,本文不展开。学习每个默认成员函数都问两件事:不写时编译器生成的行为是什么,够不够用;不够用时怎么自己实现。

5.2 构造函数
构造函数(constructor)的名字和类名相同,没有返回值,对象创建时自动调用,可以重载。它不负责给对象开空间(局部对象的空间在栈帧创建时就有了),负责初始化成员。
1.1 的 Date d; 要先调用 Init;忘记调用,成员就是不确定值。构造函数把这一步放到对象创建时自动完成。
cpp
#include <iostream>
class Date
{
public:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year, _month, _day;
};
int main()
{
Date d1;
Date d2(2024, 3, 31);
d1.Print();
d2.Print();
return 0;
}
输出:
text
1-1-1
2024-3-31
不写构造函数时,编译器生成一个无参的默认构造;一旦自己定义了任何一个构造函数,编译器就不再生成。
无参构造、全缺省构造、编译器生成的构造,都叫默认构造函数(default constructor)。三者只能存在一个:Date() 和 Date(int year = 1, int month = 1, int day = 1) 都能被 Date d; 调用,同时写会产生歧义。
Date d3(); 不是创建对象,而是声明一个返回 Date、无参数的函数。创建无参对象写 Date d3;。
如果只保留带参构造,Date d; 会找不到可用的默认构造;VS2026/MSVC 报 C2512:没有合适的默认构造函数可用。
编译器生成的构造函数对内置类型(built-in type)成员不做初始化,对自定义类型(user-defined type)成员调用其默认构造:
cpp
#include <iostream>
class Time
{
public:
Time() { std::cout << "Time()" << std::endl; }
};
class Date
{
private:
int _year;
Time _t;
};
int main()
{
Date d;
std::cout << "Date 构造完成,_year 的值不确定" << std::endl;
return 0;
}
输出:
text
Time()
Date 构造完成,_year 的值不确定
Date d; 会输出 Time(),但 _year 的值不确定。内置类型成员没有被初始化,不要假设它是 0。
如果自定义类型成员没有默认构造,编译器生成的构造函数无法完成初始化,必须用初始化列表显式调用它的构造函数;第 8 节展开。
5.3 析构函数
析构函数(destructor)的名字是 ~类名,没有参数和返回值,一个类只能有一个,对象生命周期结束时自动调用。它不销毁对象本身,负责清理对象持有的资源。
把 4.2 的 Init 和 Destroy 换成下面两个函数,其余成员不变:
cpp
class Stack
{
public:
Stack(int n = 4)
{
assert(n > 0);
_a = (int*)malloc(sizeof(int) * n);
if (_a == nullptr)
{
perror("malloc 申请空间失败");
_capacity = _top = 0;
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
// Push、Pop、Top、Empty 与 4.2 相同
private:
int* _a;
size_t _capacity;
size_t _top;
};
现在写 Stack s; 就会自动初始化,Push、Top 等成员照 4.2 的写法调用,离开作用域时自动释放。不需要 Init,也不需要 Destroy。
C 版在每个提前 return 的分支都要补 STDestroy;C++ 版无论从哪条路径返回,析构函数都会执行。
编译器生成的析构函数不处理内置类型成员,对自定义类型成员调用其析构。即使自己写了析构函数,自定义类型成员也一定会析构。
同一局部域的多个对象,后定义的先析构;对象的成员按声明顺序的逆序析构。
cpp
#include <iostream>
class A
{
public:
A(int id = 0) : _id(id) { std::cout << "A(" << _id << ")" << std::endl; }
~A() { std::cout << "~A(" << _id << ")" << std::endl; }
private:
int _id;
};
class MyQueue
{
private:
A _push;
A _pop;
};
int main()
{
A a1(1);
A a2(2);
MyQueue q;
return 0;
}
输出:
text
A(1)
A(2)
A(0)
A(0)
~A(0)
~A(0)
~A(2)
~A(1)
q 的两个成员按声明顺序构造,析构时先 _pop 后 _push;q 之后才是 a2、a1。局部对象整体后定义先析构。
6. 拷贝构造与赋值重载
6.1 拷贝构造函数
拷贝构造函数(copy constructor)是构造函数的重载:第一个参数是自身类类型的引用,其余参数都有默认值。
cpp
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
Date(const Date& d)
: _year(d._year), _month(d._month), _day(d._day) {}
Date(Date* d)
: _year(d->_year), _month(d->_month), _day(d->_day) {}
private:
int _year, _month, _day;
};
int main()
{
Date d1(2024, 7, 5);
Date d2(d1); // 拷贝构造
Date d3 = d1; // 也是拷贝构造,不是赋值
Date d4(&d1); // 普通构造:形参是 Date*
return 0;
}
Date d3 = d1; 看起来像赋值,但 d3 是新对象,调用的是拷贝构造。两个已经存在的对象之间用 = 才是赋值。
第一个参数必须是引用。写成 Date(Date d) 会引发无穷递归:传值传参本身又要调用拷贝构造,编译器直接报错。
参数加 const 是为了能拷贝 const 对象,也表明不修改源对象。Date(Date* d) 只是普通构造,不是拷贝构造。
不写拷贝构造时,编译器生成默认版本:内置类型成员逐字节复制,自定义类型成员调用其拷贝构造。这种复制叫浅拷贝(shallow copy)。
对 Date 来说,浅拷贝够用:三个 int 各复制一份。对 Stack 来说,_a 指向堆内存,浅拷贝会让两个对象指向同一块缓冲区。

Stack s2 = s1; 后,s1._a 和 s2._a 指向同一块内存。两个对象析构时各 free 一次,同一块内存被释放两次,程序崩溃。
深拷贝(deep copy)要重新申请一块空间,再把数据复制过去。在 Stack 类中写:
cpp
Stack(const Stack& st)
: _a((int*)malloc(sizeof(int) * st._capacity)),
_capacity(st._capacity), _top(st._top)
{
if (_a == nullptr)
{
perror("malloc 申请空间失败");
_capacity = _top = 0;
return;
}
memcpy(_a, st._a, sizeof(int) * st._top);
}
申请失败时把 _capacity 和 _top 置 0,避免后面使用未初始化的计数。memcpy 只复制 _top 个有效元素,不复制容量里的空闲部分;使用它需要包含 <cstring>。
判断规则:析构函数释放了资源,拷贝构造通常也要自己写;否则默认浅拷贝会让两个对象共用同一份资源。
6.2 什么时候调用拷贝构造
拷贝构造在三种场景下会被调用:用已有对象初始化新对象、传值传参、传值返回。
| 场景 | 是否调用拷贝构造 | 说明 |
|---|---|---|
Date d2(d1);、Date d2 = d1; |
是 | 用已有对象初始化新对象 |
void f(Date d); f(d1); |
是 | 形参本身是新对象 |
Date f() { Date tmp; return tmp; } |
是,可能被优化掉 | 返回值对象或临时对象 |
Date& f() { ... } |
否 | 返回引用,不拷贝 |
Date d4(&d1); |
否 | 形参是指针,走普通构造 |
下面几个函数对应表中的场景,接着 6.1 的 Date 类看:
cpp
void Func1(Date d)
{
// 形参 d 是 d1 的拷贝
}
Date Func2()
{
Date tmp(2024, 7, 5);
return tmp; // 传值返回,通常构造返回值对象
}
Date& Func3()
{
Date tmp(2024, 7, 5);
return tmp; // 悬空引用:tmp 在函数结束时销毁
}
int main()
{
Date d1(2024, 7, 5);
Func1(d1); // 拷贝构造形参
Date d2(d1); // 拷贝构造
Date d3 = d1; // 拷贝构造,不是赋值
Date d4(&d1); // 普通构造:形参是 Date*
return 0;
}
传值返回会产生临时对象;编译器可能省略这次拷贝,第 11 节再讲。传引用返回不拷贝,但返回局部对象的引用会变成悬空引用(dangling reference):函数结束,tmp 已销毁,引用指向无效内存。
6.3 赋值运算符重载
赋值重载是运算符重载(operator overloading)的一种。运算符重载用 operator 加运算符作为函数名,给类类型对象定义这个运算符的含义。
规则:至少有一个操作数是类类型;不能改变内置类型的运算符含义;不能创造新运算符;不能改变优先级、结合性和操作数个数。
.*、::、sizeof、?:、. 这 5 个运算符不能重载。重载成成员函数时,第一个操作数由 this 承担,参数比操作数少一个。
++ 的前置和后置都叫 operator++,C++ 规定后置版本多一个 int 形参来区分;<<、>> 通常重载成全局函数,9.2 节会看到。
赋值运算符重载用于两个已经存在的对象之间赋值,必须写成成员函数。在 Date 类中写:
cpp
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
参数用 const Date&,避免传值多一次拷贝;返回 Date&,少一次拷贝,也支持连续赋值 d1 = d2 = d3。
if (this != &d) 防止自己给自己赋值。返回 *this 是因为 d1 = d2 的结果应该是 d1,不是临时副本。
不写赋值重载时,编译器生成默认版本:内置类型成员浅拷贝,自定义类型成员调用其赋值重载。Stack 这类持有资源的类必须深拷贝,在 Stack 类中写:
cpp
Stack& operator=(const Stack& st)
{
if (this != &st)
{
int* tmp = (int*)malloc(sizeof(int) * st._capacity);
if (tmp == nullptr)
{
perror("malloc 申请空间失败");
return *this;
}
memcpy(tmp, st._a, sizeof(int) * st._top);
free(_a);
_a = tmp;
_capacity = st._capacity;
_top = st._top;
}
return *this;
}
先申请新空间,复制数据,成功后再释放旧空间;如果先 free(_a) 再申请失败,原数据就丢了。
| 对比项 | 拷贝构造 | 赋值重载 |
|---|---|---|
| 使用时机 | 用已有对象初始化新对象 | 两个已存在对象之间赋值 |
| 典型写法 | Date d2(d1);、Date d2 = d1; |
d1 = d2; |
| 是否创建新对象 | 是 | 否 |
| 返回类型 | 无 | Date&,支持连续赋值 |
7. 用 Date 串起运算符重载
7.1 const 成员函数
把 const 写在成员函数参数列表后面,就是 const 成员函数。它修饰隐含的 this:Date* const this 变成 const Date* const this,函数内不能修改成员变量。
前面的 Print 没有加 const,因为还没讲到;现在补上,看看它约束了什么。
cpp
#include <iostream>
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
void Print() const
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year, _month, _day;
};
int main()
{
Date d1;
d1.Print();
const Date d2(2024, 2, 29);
d2.Print();
return 0;
}
输出:
text
1900-1-1
2024-2-29
普通对象可以调用 const 成员函数,权限缩小;const 对象只能调用 const 成员函数,不能调用普通成员函数,否则是权限放大。
| 对象 \ 成员函数 | 普通成员函数 | const 成员函数 |
|---|---|---|
| 普通对象 | 可以 | 可以(权限缩小) |
| const 对象 | 不可以(权限放大) | 可以 |
如果某个成员在 const 成员函数里确实要修改,可以用 mutable 修饰,例如缓存或计数器。它打破 const 约束,非必要不用。
operator+ 不修改原对象,只是复制一份再改,所以能声明成 const;operator+=、operator++ 会修改对象,不能加 const。只读接口都加上 const,const 对象才能正常使用。
7.2 比较运算符
六个比较运算符不用各写一套。先在 Date 类中声明,再在 Date.cpp 中定义;完整的类声明在 7.5。先写 < 和 ==,其余四个用它们组合:
cpp
bool Date::operator<(const Date& d) const
{
if (_year != d._year)
return _year < d._year;
if (_month != d._month)
return _month < d._month;
return _day < d._day;
}
bool Date::operator==(const Date& d) const
{
return _year == d._year && _month == d._month && _day == d._day;
}
bool Date::operator<=(const Date& d) const { return *this < d || *this == d; }
bool Date::operator>(const Date& d) const { return !(*this <= d); }
bool Date::operator>=(const Date& d) const { return !(*this < d); }
bool Date::operator!=(const Date& d) const { return !(*this == d); }
< 先比年,再比月,最后比日;== 三个字段都相等才返回 true。<= 是 < 或 ==,> 是 !(<=),>= 是 !(<),!= 是 !(==)。
这样只需要维护两处字段比较,其余四个不会再写错。六个函数都不修改对象,全部声明成 const。
7.3 日期加减与前后置 ++
+= 修改对象并返回引用;+ 复制一份再调用 +=,返回新对象,不改变原对象。在 Date.cpp 中定义:
cpp
Date& Date::operator+=(int day)
{
if (day < 0)
return *this -= -day;
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
if (++_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
Date Date::operator+(int day) const
{
Date tmp = *this;
tmp += day;
return tmp;
}
GetMonthDay 处理闰年:能被 4 整除且不能被 100 整除,或者能被 400 整除,二月是 29 天。day 为负数时转给 -= 处理。
cpp
Date& Date::operator-=(int day)
{
if (day < 0)
return *this += -day;
_day -= day;
while (_day <= 0)
{
if (--_month == 0)
{
_month = 12;
--_year;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
Date Date::operator-(int day) const
{
Date tmp = *this;
tmp -= day;
return tmp;
}
int Date::operator-(const Date& d) const
{
Date max = *this;
Date min = d;
int flag = 1;
if (max < min)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++min;
++n;
}
return n * flag;
}
- 有两个重载:Date operator-(int) 返回日期,int operator-(const Date&) 返回相差天数。参数类型不同,编译器能区分调用哪个。
日期差先确定较大的日期,再逐日 ++min,直到相等;flag 保留方向,d1 - d2 和 d2 - d1 符号相反。
cpp
Date& Date::operator++()
{
*this += 1;
return *this;
}
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
前置 ++ 修改后返回引用,后置 ++ 先保存旧值,再修改,返回旧值的副本。后置版本的 int 参数只是占位,调用时不用传。-- 的写法相同,把 += 1 换成 -= 1。
复杂度:比较是 O(1);+=、-= 按跨过的月份数推进,O(k);+、- 先复制再调用,复杂度相同;日期差逐日推进,O(n),n 是相差天数。
构造、GetMonthDay、CheckDate、Print 都是 O(1);++、-- 每次最多跨一个月,也是 O(1)。
7.4 取地址运算符重载
编译器会生成两个取地址运算符:Date* operator&() 和 const Date* operator&() const,分别返回普通对象和 const 对象的地址。一般不用自己写。
cpp
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
private:
int _year, _month, _day;
};
只有不想让外部拿到真实地址时才自己写,例如返回 nullptr 或代理地址。这种写法会让调用方意外,除非有明确需求,否则保留默认版本。
7.5 完整实现与测试
把声明和定义分开,得到三个文件。Date.h:
cpp
#pragma once
#include <iostream>
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1);
void Print() const;
bool CheckDate() const;
int GetMonthDay(int year, int month) const;
bool operator<(const Date& d) const;
bool operator==(const Date& d) const;
bool operator<=(const Date& d) const;
bool operator>(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator!=(const Date& d) const;
Date& operator+=(int day);
Date operator+(int day) const;
Date& operator-=(int day);
Date operator-(int day) const;
int operator-(const Date& d) const;
Date& operator++();
Date operator++(int);
Date& operator--();
Date operator--(int);
private:
int _year;
int _month;
int _day;
};
Date.cpp:
cpp
#include "Date.h"
#include <cassert>
#include <iostream>
Date::Date(int year, int month, int day)
: _year(year), _month(month), _day(day)
{
if (!CheckDate())
std::cout << "日期非法: " << _year << "-" << _month << "-" << _day << std::endl;
}
bool Date::CheckDate() const
{
return _month >= 1 && _month <= 12
&& _day >= 1 && _day <= GetMonthDay(_year, _month);
}
int Date::GetMonthDay(int year, int month) const
{
assert(month >= 1 && month <= 12);
static const int days[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0))
return 29;
return days[month];
}
void Date::Print() const
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
bool Date::operator<(const Date& d) const
{
if (_year != d._year)
return _year < d._year;
if (_month != d._month)
return _month < d._month;
return _day < d._day;
}
bool Date::operator==(const Date& d) const
{
return _year == d._year && _month == d._month && _day == d._day;
}
bool Date::operator<=(const Date& d) const { return *this < d || *this == d; }
bool Date::operator>(const Date& d) const { return !(*this <= d); }
bool Date::operator>=(const Date& d) const { return !(*this < d); }
bool Date::operator!=(const Date& d) const { return !(*this == d); }
Date& Date::operator+=(int day)
{
if (day < 0)
return *this -= -day;
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
if (++_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
Date Date::operator+(int day) const
{
Date tmp = *this;
tmp += day;
return tmp;
}
Date& Date::operator-=(int day)
{
if (day < 0)
return *this += -day;
_day -= day;
while (_day <= 0)
{
if (--_month == 0)
{
_month = 12;
--_year;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
Date Date::operator-(int day) const
{
Date tmp = *this;
tmp -= day;
return tmp;
}
int Date::operator-(const Date& d) const
{
Date max = *this;
Date min = d;
int flag = 1;
if (max < min)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++min;
++n;
}
return n * flag;
}
Date& Date::operator++()
{
*this += 1;
return *this;
}
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
Date& Date::operator--()
{
*this -= 1;
return *this;
}
Date Date::operator--(int)
{
Date tmp(*this);
*this -= 1;
return tmp;
}
main.cpp:
cpp
#include "Date.h"
int main()
{
Date d1(2024, 2, 28);
Date d2 = d1 + 1;
std::cout << "d1 + 1 = ";
d2.Print();
Date d3(2023, 2, 28);
Date d4 = d3 + 1;
std::cout << "d3 + 1 = ";
d4.Print();
Date d5(2024, 12, 31);
Date d6 = d5 + 1;
std::cout << "d5 + 1 = ";
d6.Print();
Date d7(2024, 3, 1);
Date d8 = d7 - 1;
std::cout << "d7 - 1 = ";
d8.Print();
std::cout << "d1 < d7: " << (d1 < d7) << std::endl;
std::cout << "d1 == d1: " << (d1 == d1) << std::endl;
Date d9(2024, 1, 1);
Date d10(2025, 1, 1);
std::cout << "d10 - d9 = " << (d10 - d9) << std::endl;
Date d11 = d1++;
std::cout << "后置++ 返回: ";
d11.Print();
std::cout << "d1 变成: ";
d1.Print();
Date d12 = ++d1;
std::cout << "前置++ 返回: ";
d12.Print();
std::cout << "d1 变成: ";
d1.Print();
return 0;
}
在 VS2026 中把三个文件加入同一个项目,编译运行。输出:
text
d1 + 1 = 2024-2-29
d3 + 1 = 2023-3-1
d5 + 1 = 2025-1-1
d7 - 1 = 2024-2-29
d1 < d7: 1
d1 == d1: 1
d10 - d9 = 366
后置++ 返回: 2024-2-28
d1 变成: 2024-2-29
前置++ 返回: 2024-3-1
d1 变成: 2024-3-1
Date.h 只放声明,Date.cpp 放定义,main.cpp 做测试;#pragma once 防止头文件被重复包含。成员函数在类外定义时要带 Date::,声明和定义里的 const 都要写。
8. 初始化列表与 explicit
8.1 初始化列表
在构造函数体内写 _year = year; 是"先构造、再赋值"。初始化列表(member initializer list)在成员定义时直接给初值,以冒号开始,逗号分隔。
cpp
#include <iostream>
class Time
{
public:
Time(int hour = 0) : _hour(hour)
{
std::cout << "Time(" << _hour << ")" << 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)
{}
private:
int _year, _month, _day;
Time _t;
int& _ref;
const int _n;
};
int main()
{
int x = 10;
Date d(x);
std::cout << "ok" << std::endl;
return 0;
}
输出:
text
Time(12)
ok
_t(12) 调用 Time(int);_ref(x) 把引用绑定到 x;_n(1) 初始化 const 成员。成员在进入函数体之前就已经构造完成。
引用成员、const 成员、没有默认构造的类类型成员,必须写进初始化列表;否则 VS2026/MSVC 分别报 C2530、C2789、C2512。
每个成员在初始化列表里只能出现一次。无论是否显式写,每个构造函数都有初始化列表;无论是否显式列出,每个成员都要走这一步。
没列进初始化列表的成员,按下面的规则处理:
| 成员情况 | 结果 |
|---|---|
| 声明处有缺省值 | 用该缺省值 |
| 内置类型且无缺省值 | 是否初始化取决于编译器,值不确定 |
| 类类型且能默认构造 | 调用默认构造 |
| 类类型且无默认构造 | 编译错误 |
类类型成员如果有默认构造,写在函数体里赋值也能编译,但会先默认构造再赋值,多一次操作;能用初始化列表就用初始化列表。
8.2 声明处缺省值与初始化顺序
C++11 允许在成员声明处写 = 值。这不是在声明处初始化,而是给初始化列表准备缺省值;只有列表没提到该成员时才用。
cpp
class Time
{
public:
Time(int hour = 0) : _hour(hour) {}
private:
int _hour;
};
class Date
{
public:
Date() : _month(2) {}
private:
int _year = 1;
int _month = 1;
int _day;
Time _t = 1;
};
Date d; 中 _year 用缺省值 1,_month 被初始化列表的 2 覆盖,_day 是内置类型且没有缺省值,值不确定;_t = 1 会用 Time(int) 构造。
内置类型不会自动清零。没有在初始化列表出现、声明处也没有缺省值的成员,C++ 不规定是否初始化,不要假设它是 0。
初始化顺序由声明顺序决定,和初始化列表的书写顺序无关:
cpp
#include <iostream>
class A
{
public:
A(int a) : _a1(a), _a2(_a1) {}
void Print() const
{
std::cout << _a1 << " " << _a2 << std::endl;
}
private:
int _a2 = 2;
int _a1 = 2;
};
int main()
{
A a(1);
a.Print();
return 0;
}
_a2 先声明,所以先初始化;它的初值是 _a1,但 _a1 还没初始化,读到的是不确定值。然后 _a1 才初始化为 a。这是未定义行为。
声明处的 = 2 被初始化列表里的 _a2(_a1) 覆盖,不参与这次初始化。
把声明顺序和初始化列表顺序写成一致,能避免这类问题;部分编译器在两者不一致时会给出警告。
8.3 explicit 与隐式类型转换
如果类有一个可以用一个实参调用的构造函数,编译器允许把该实参隐式转换成类类型对象。这个构造函数叫转换构造函数(converting constructor)。
cpp
class Point
{
public:
explicit Point(int x) : _x(x) {}
int Get() const { return _x; }
private:
int _x;
};
int main()
{
Point p1(1); // 直接初始化,可以
Point p2{2}; // 直接列表初始化,可以
// Point p3 = 3; // 隐式转换,编译错误
// Point p4 = {4}; // 隐式转换,编译错误
// const Point& r = 5; // 隐式转换,编译错误
const Point& r = Point(5);
return 0;
}
不加 explicit 时,Point p = 1; 会先构造一个临时 Point(1),再拷贝给 p;编译器通常会把这两步合并。加上 explicit 后,这条隐式转换被禁止,所以上面的 Point 把相关写法注释掉了。
explicit 只禁止隐式转换。Point p(1);、Point p{1};、static_cast<Point>(1) 仍然可以编译。
| 写法 | 含义 | 构造函数为 explicit 时 |
|---|---|---|
Point p(1); |
直接初始化 | 允许 |
Point p{1}; |
直接列表初始化 | 允许 |
Point p = 1; |
拷贝初始化,隐式转换 | 禁止 |
Point p = {1}; |
拷贝列表初始化 | 禁止 |
const Point& r = 1; |
临时对象绑定常引用 | 禁止 |
f(1)(参数为 Point) |
实参隐式转换 | 禁止 |
单参数构造函数建议默认加 explicit,除非确实需要让内置类型自动转换成类类型。Stack(int n = 4) 这种构造函数也可以加,避免 Stack s = 4; 这种意外写法。
9. static 成员与友元
9.1 静态成员
用 static 修饰的成员变量叫静态成员变量(static member variable):属于类,被所有对象共享,不存放在对象里,存放在静态区。
静态成员变量必须在类外定义并初始化;声明处不能给缺省值(C++17 的 inline static 例外)。访问受访问限定符限制。
cpp
#include <iostream>
class A
{
public:
A() { ++_scount; }
A(const A&) { ++_scount; }
~A() { --_scount; }
static int GetACount() { return _scount; }
private:
static int _scount;
};
int A::_scount = 0;
int main()
{
std::cout << A::GetACount() << std::endl;
A a1, a2;
A a3(a1);
std::cout << A::GetACount() << std::endl;
std::cout << a1.GetACount() << std::endl;
std::cout << sizeof(A) << std::endl;
return 0;
}
输出:
text
0
3
3
1
int A::_scount = 0; 是定义并初始化,不是类内的声明;只写类内声明、不写类外定义,链接时会报找不到定义。
构造函数和拷贝构造让计数加一,析构让它减一,GetACount 返回当前存活对象数。sizeof(A) 是 1,因为 _scount 不在对象里。
静态成员函数没有 this,只能访问静态成员,不能直接访问非静态成员;可以通过 A::GetACount() 或对象调用。
| 对比项 | 静态成员 | 非静态成员 |
|---|---|---|
| 存储位置 | 静态区 | 对象内部 |
| 归属 | 所有对象共享 | 每个对象独立 |
是否有 this |
否 | 是 |
| 访问方式 | A::member 或 对象.member |
对象.member |
| 能否访问非静态成员 | 否 | 是 |
| 声明处能否给缺省值 | 不能(C++17 inline static 例外) |
能 |
是否计入 sizeof |
否 | 是 |
C++17 起可以写 inline static int _scount = 0;,在类内直接定义并初始化;本文按传统的类内声明、类外定义写法。
9.2 友元函数
友元(friend)让一个非成员函数访问类的私有和保护成员。在类内用 friend 声明,函数本身定义在类外。
cpp
#include <iostream>
class Date
{
friend std::ostream& operator<<(std::ostream& out, const Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
private:
int _year, _month, _day;
};
std::ostream& operator<<(std::ostream& out, const Date& d)
{
out << d._year << "-" << d._month << "-" << d._day;
return out;
}
int main()
{
Date d(2024, 2, 29);
std::cout << d << std::endl;
return 0;
}
输出:
text
2024-2-29
把这条 friend 声明加到 7.5 的 Date 类里,把函数定义加到 Date.cpp,就可以用 std::cout << d 输出日期。
operator<< 必须重载成全局函数。写成成员函数时,this 会抢占第一个参数位置,调用变成 d << std::cout,不符合 std::cout << d 的习惯。
友元声明可以放在类内任何位置,不受 public / private 影响;友元函数不是成员函数,一个函数可以是多个类的友元。
operator>> 的写法相同,只是参数换成 std::istream& 和非 const 的 Date&,因为要往对象里写数据。
友元会增加类之间的耦合,也会破坏封装。能用公开接口解决时不要用;流运算符是常见的合理场景,因为要么开放成员,要么加一组 getter,友元让 operator<< 直接读私有成员。
9.3 友元类
在 Date 中写 friend class DateTool;,DateTool 的所有成员函数都能访问 Date 的私有和保护成员。
cpp
#include <iostream>
class Date
{
friend class DateTool;
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
private:
int _year, _month, _day;
};
class DateTool
{
public:
static bool IsLeap(const Date& d)
{
return (d._year % 4 == 0 && d._year % 100 != 0) || d._year % 400 == 0;
}
};
int main()
{
Date d(2024, 2, 29);
std::cout << DateTool::IsLeap(d) << std::endl;
return 0;
}
输出:
text
1
友元关系有三个性质:单向、不传递、不继承。
| 性质 | 含义 |
|---|---|
| 单向 | A 是 B 的友元,不代表 B 是 A 的友元 |
| 不传递 | A 是 B 的友元、B 是 C 的友元,不代表 A 是 C 的友元 |
| 不继承 | 友元关系不会被派生类继承 |
友元类同样增加耦合。只把确实需要访问内部表示的辅助类设为友元,不要为了方便把所有类都开放。
10. 内部类与匿名对象
10.1 内部类
定义在另一个类内部的类叫内部类(nested class)。它是独立的类,只是名字受外部类类域和访问限定符限制。
cpp
#include <iostream>
class A
{
private:
static int _k;
int _h = 1;
public:
class B
{
public:
void foo(const A& a) const
{
std::cout << _k << " " << a._h << std::endl;
}
};
};
int A::_k = 1;
int main()
{
std::cout << sizeof(A) << std::endl;
A::B b;
A a;
b.foo(a);
return 0;
}
输出:
text
4
1 1
创建内部类对象要写 A::B b;。A 的对象里不包含 B 对象,sizeof(A) 只计算非静态成员 _h,所以是 4。
内部类默认是外部类的友元,可以访问外部类的私有成员,包括静态成员 _k 和对象成员 a._h;反过来,外部类没有访问内部类私有成员的特权。
把 B 放在 private 里,B 就成了 A 的专属实现细节,外部不能直接使用。两个类紧密关联、且一个主要给另一个使用时,可以这样设计。
| 对比项 | 内部类 | 普通成员对象 |
|---|---|---|
| 占用外部对象空间 | 否 | 是 |
| 生命周期 | 独立,可单独创建 | 随外部对象 |
| 访问外部类私有成员 | 默认可 | 不可以 |
影响外部类 sizeof |
不影响 | 影响 |
10.2 匿名对象
用 类型(实参) 直接创建的对象叫匿名对象(anonymous object);用 类型 对象名(实参) 创建的是有名对象。
cpp
#include <iostream>
class A
{
public:
A(int a = 0) : _a(a) { std::cout << "A(" << _a << ")" << std::endl; }
~A() { std::cout << "~A(" << _a << ")" << std::endl; }
void Print() const { std::cout << _a << std::endl; }
private:
int _a;
};
int main()
{
A a1;
A();
A(1);
A a2(2);
A().Print();
const A& r = A(3);
r.Print();
std::cout << "end" << std::endl;
return 0;
}
输出:
text
A(0)
A(0)
~A(0)
A(1)
~A(1)
A(2)
A(0)
0
~A(0)
A(3)
3
end
~A(3)
~A(2)
~A(0)
A a1; 是有名对象,生命周期到作用域结束;A();、A(1); 是匿名对象,生命周期到当前完整表达式结束,通常就是这一行。
A a1(); 不是创建对象,而是函数声明。创建无参对象写 A a1;。
匿名对象适合只调用一次成员函数的场景,例如 A().Print();;调用完立刻析构,不需要为一次调用起名字。
const A& r = A(3); 会把临时对象的生命周期延长到 r 结束,这是常引用绑定临时对象的规则。
| 对比项 | 有名对象 | 匿名对象 |
|---|---|---|
| 定义形式 | A a(1); |
A(1); |
| 生命周期 | 作用域结束 | 当前完整表达式结束 |
| 典型用途 | 反复使用 | 只调用一次成员函数、临时传参 |
11. 拷贝省略
传值传参和传值返回会产生拷贝。编译器在保证语义不变的前提下,可以把连续几次拷贝合并成一次构造,这叫拷贝省略(copy elision)。
cpp
#include <iostream>
class A
{
public:
A(int a = 0) : _a(a) { std::cout << "A(int)" << std::endl; }
A(const A& aa) : _a(aa._a) { std::cout << "A(const A&)" << std::endl; }
A& operator=(const A& aa)
{
std::cout << "operator=" << std::endl;
if (this != &aa)
_a = aa._a;
return *this;
}
~A() { std::cout << "~A()" << std::endl; }
private:
int _a;
};
void f1(A aa) {}
A f2()
{
A aa;
return aa;
}
int main()
{
A aa1;
f1(aa1);
f1(1);
f1(A(2));
f2();
A aa2 = f2();
aa1 = f2();
return 0;
}
f1(aa1) 的 aa1 已经存在,形参必须是新对象,这次拷贝省不掉。f1(1) 先构造临时 A(1) 再拷贝给形参,编译器可以直接用 1 构造形参。
f1(A(2)) 是匿名对象加拷贝,通常合并成一次构造;f2() 是局部对象加返回值临时对象,通常也合并;A aa2 = f2() 连续两次拷贝,通常合并成一次构造。
aa1 = f2() 的赋值不能省,因为 aa1 已经存在;临时对象的构造可能被合并。
| 调用场景 | 未优化时的步骤 | 常见优化结果 |
|---|---|---|
f1(aa1) |
拷贝 1 次 | 不变,拷贝省不掉 |
f1(1) |
构造 1 次 + 拷贝 1 次 | 构造 1 次 |
f1(A(2)) |
构造 1 次 + 拷贝 1 次 | 构造 1 次 |
f2() |
构造 1 次 + 拷贝 1 次 | 构造 1 次 |
A aa2 = f2() |
构造 1 次 + 拷贝 2 次 | 构造 1 次 |
aa1 = f2() |
构造 1 次 + 拷贝 1 次 + 赋值 1 次 | 构造 1 次 + 赋值 1 次 |
表中只统计该行表达式本身引起的构造、拷贝和赋值;A aa1; 那次构造不计入。
C++17 起,用同类型的纯右值(prvalue)初始化对象时,拷贝省略是语言规则,不再是可选优化,典型例子是 A a = A(1);。命名返回值(如 f2() 的 aa)是否省略仍由编译器决定。
拷贝是否被省略取决于编译器、版本、优化选项和具体表达式。不要把拷贝构造的调用次数当作程序逻辑,也不要在拷贝构造里写必须执行的业务代码。析构次数同理,少一次拷贝就少一次析构。
12. 小结
- 类把数据和操作数据的函数放进同一个类型;
public/private控制外部能访问什么,类外定义成员要用类名::指定类域。 - 类不占对象空间;对象只存非静态成员变量,成员函数在代码段共用;对象大小按内存对齐计算,空类占 1 字节。
- 非静态成员函数隐含
this参数;const 成员函数把this变成const 类名* const,const 对象只能调用 const 成员函数。 - 构造函数在对象创建时初始化,析构函数在生命周期结束时清理;局部对象后定义先析构,有资源申请时要自己写析构。
- 拷贝构造的第一个参数必须是类类型引用;默认浅拷贝,持有资源的类要深拷贝;赋值重载必须写成成员函数,检查自赋值并返回
*this。 - 运算符重载不能改变优先级、结合性和操作数个数;比较运算符只写
<和==,其余复用;初始化列表负责成员定义时的初始化,顺序由声明顺序决定。 static成员属于类、存放在静态区;友元单向、不传递、不继承;内部类不占外部对象空间;匿名对象生命周期到当前表达式结束;拷贝或析构次数不能当作程序逻辑。