# 友元
在 C++ 中,类的私有成员(包括私有数据成员和私有成员函数)只能被该类自己的成员函数以及友元函数 / 类访问。友元关系是一种特殊的访问权限,允许非成员函数或类访问另一个类的私有成员。
友元关系通过在类内部使用 friend
关键字声明建立。可以声明友元函数或友元类:
- 友元函数:可以是 普通函数,也可以是 成员函数,它们可以访问声明它们为友元的类的私有成员。
- 友元类:整个类的所有成员函数都能访问声明它们为友元的类的私有成员。
友元的三种形式:普通函数、成员函数、友元类
# 普通函数形式
程序中有 Point
类,需要求取两个点的距离。按照设想,我们定义一个普通函数 distance
,接收两个 Point
对象作为参数,通过公式计算这两个点之间的距离。但 Point
的 _ix
和 _iy
是私有成员,在类外不能通过对象访问。
可以将 distance
函数声明为友元,允许它访问类的私有成员。这样, distance
函数可以直接读取两个 Point
对象的坐标数据,并计算它们之间的距离。
以下是完整的示例代码,包括 Point
类和友元函数 distance
:
#include <iostream> | |
#include <cmath> // 包含 sqrt 函数 | |
class Point { | |
public: | |
Point(int x, int y) : _ix(x), _iy(y) {} | |
// 声明友元函数 | |
// 友元的第一种形式 | |
// 普通函数声明为一个类的友元函数 | |
// 那么在这个函数中可以访问类的私有成员 | |
friend float distance(const Point &lhs, const Point &rhs); | |
private: | |
int _ix; | |
int _iy; | |
}; | |
// 计算两点之间的距离 | |
// 建立一个观念 | |
// 如果函数的参数为对象 | |
// 形参首先想到 const 引用的形式 | |
float distance(const Point &lhs, const Point &rhs) { | |
return sqrt((lhs._ix - rhs._ix) * (lhs._ix - rhs._ix) + | |
(lhs._iy - rhs._iy) * (lhs._iy - rhs._iy)); | |
} | |
int main() { | |
Point p1(2, 3); | |
Point p2(5, 7); | |
float dist = distance(p1, p2); | |
std::cout << "Distance: " << dist << std::endl; | |
return 0; | |
} |
# 成员函数形式
要让一个类的成员函数访问另一个类的私有成员,可以在第二个类中声明第一个类的成员函数为友元。这需要在第二个类的类定义中进行友元声明,并确保声明的友元函数与定义的函数完全匹配。
如果 distance
函数不再是一个普通函数,而是 Line
类的一个成员函数,也就是说需要在一个类( Line
)的成员函数中访问另一个类( Point
)的私有成员,那么又该如何实现呢?
-
如果将
Point
类定义在Line
类之前,Line
类的成员函数要访问Point
类的私有成员,需要在Point
类中将Line
的这个成员函数设为友元函数 —— 此时编译器并不认识Line
类; -
如果将
Line
类定义在Point
类之前,那么distance
函数需要接受两个const Point &
作为参数 —— 此时编译器不认识Point
类;
解决方法:在 Line
前面做一个 Point
类的前向声明;
但如果将 distance
的函数体写在 Line
类中,编译器虽然知道了有一个 Point
类,但并不知道 Point
类具体有什么成员,所以此时在函数体中访问 _ix
、 _iy
都会报错,编译器并不认识它们。
// 前向声明 | |
class Point; | |
class Line{ | |
public: | |
float distance(const Point & lhs, const Point & rhs){ | |
return sqrt((lhs._ix - rhs._ix)*(lhs._ix - rhs._ix) + //error,有问题,不知道 Point 的具体实现 | |
(lhs._iy - rhs._iy)*(lhs._iy - rhs._iy)); | |
} | |
}; | |
class Point{ | |
public: | |
Point(int x, int y) | |
: _ix(x) | |
, _iy(y) | |
{} | |
friend float Line::distance(const Point & lhs, const Point & rhs); | |
private: | |
int _ix; | |
int _iy; | |
}; |
前向声明的用处:进行了前向声明的类,可以以引用或指针的形式作为函数的参数,只要不涉及到对该类对象具体成员的访问,编译器可以通过。
(让编译器认识这个类,但是注意如果只进行前向声明,这个类的具体实现没有的话,无法使用这个类的对象,无法创建)
以下是使用友元成员函数的示例:
#include <iostream> | |
#include <cmath> // 包含 sqrt 函数 | |
// 前向声明 | |
class Point; | |
class Line { | |
public: | |
float distance(const Point& lhs, const Point& rhs); | |
}; | |
class Point { | |
public: | |
Point(int x, int y) : _ix(x), _iy(y) {} | |
friend float Line::distance(const Point& lhs, const Point& rhs); | |
private: | |
int _ix; | |
int _iy; | |
}; | |
// Line 类的成员函数定义 | |
float Line::distance(const Point& lhs, const Point& rhs) { | |
return sqrt((lhs._ix - rhs._ix) * (lhs._ix - rhs._ix) + | |
(lhs._iy - rhs._iy) * (lhs._iy - rhs._iy)); | |
} | |
int main() { | |
Point p1(2, 3); | |
Point p2(5, 7); | |
Line line; | |
float dist = line.distance(p1, p2); | |
std::cout << "Distance: " << dist << std::endl; | |
return 0; | |
} |
输出
Distance: 5
# 友元类
假设类 Line
中不止有一个 distance
成员函数,还有其他成员函数,它们都需要访问 Point
的私有成员,如果还像上面的方式一个一个设置友元,就比较繁琐了,可以直接将 Line
类设置为 Point
的友元类。在 Point
类中声明 Line
类是本类的友元类,那么 Line
类中的所有成员函数中都可以访问 Point
类的私有成员。
通过声明友元类,可以使得该友元类的所有成员函数都能访问声明类的私有和保护成员。这种方法在需要广泛访问私有成员时非常有用,尤其是在设计类库或框架时。
以下是使用友元类的示例:
#include <iostream> | |
#include <cmath> | |
class Point { | |
public: | |
Point(int x, int y) : _ix(x), _iy(y) {} | |
// 声明友元类 | |
friend class Line; | |
private: | |
int _ix; | |
int _iy; | |
}; | |
class Line { | |
public: | |
Line(const Point& p1, const Point& p2) : _p1(p1), _p2(p2) {} | |
// 使用友元类权限访问 Point 的私有成员 | |
float distance() const { | |
return sqrt((_p1._ix - _p2._ix) * (_p1._ix - _p2._ix) + | |
(_p1._iy - _p2._iy) * (_p1._iy - _p2._iy)); | |
} | |
private: | |
Point _p1, _p2; | |
}; | |
int main() { | |
Point p1(2, 3); | |
Point p2(5, 7); | |
Line line(p1, p2); | |
std::cout << "Distance: " << line.distance() << std::endl; | |
return 0; | |
} |
输出:
Distance: 5
注意事项:
- 信息隐藏:虽然友元类可以访问私有成员,但这可能会破坏封装性和信息隐藏,因此应谨慎使用。
- 设计权衡:在设计类时,需要权衡封装性和易用性,友元类提供了一种扩展类接口的灵活性,但同时也带来了风险。
# 友元的特点
- 访问权限:友元函数或友元类可以访问声明它的类的所有成员,包括私有和保护成员。
- 破坏封装性:通过允许外部函数或类访问私有成员,友元关系破坏了类的封装性。这是其设计的一部分,使得友元关系需要谨慎使用。
- 使用限制:友元关系不是类型安全的,因为它允许违反正常的访问控制。
- 单向性:声明友元关系是单向的。如果类 A 是类 B 的友元,并不意味着类 B 就是类 A 的友元,除非显式声明。
- 非传递性:友元关系不具有传递性。即使类 A 是类 B 的友元,类 B 是类 C 的友元,也不能推断出类 A 是类 C 的友元。
- 不继承友元:派生类不会继承基类的友元。每个类需要独立声明友元关系。
# 运算符重载
# 运算符重载的介绍
在 C++ 中,预定义的运算符默认仅适用于基本的内置数据类型,如整数和浮点数。对于用户定义的类型,这些运算符并不直接适用。虽然可以通过定义特定的函数来模拟运算符的行为,但这种方法缺乏直观性和简洁性。C++ 提供了运算符重载(Operator Overloading)的特性,允许我们为自定义类型定义运算符的行为。
运算符重载的核心思想是,通过扩展运算符的功能,使得自定义类型在操作时能够与内置类型保持一致性。这样,我们就能够利用运算符的直观表达能力,以一种简洁易懂的方式定义自定义类型的行为。此外,运算符重载还有助于提高代码的可维护性和可复用性,因为它允许我们以一种更自然的方式处理自定义数据类型。
运算符重载可以定义自定义类型的运算符行为,从而使得这些类型在进行运算时表现得像内置类型一样。
能够重载的运算符有 42 个:
+ |
- |
* |
/ |
% |
^ |
---|---|---|---|---|---|
& |
| |
~ |
! |
= |
< |
> |
+= |
-= |
*= |
/= |
%= |
^= |
&= |
|= |
>> |
<< |
>>= |
<<= |
== |
!= |
> = |
<= |
&& |
|| |
++ |
-- |
->* |
-> |
, |
[] |
() |
new |
delete |
new [] |
delete [] |
不能重载的运算符包括:
.
:成员访问运算符。.*
:成员指针访问运算符。?:
:三目运算符。::
:作用域限定符。sizeof
:长度运算符。
# 运算符重载的规则
在 C++ 中,运算符重载遵循以下规则:
- 自定义类型要求:运算符重载的操作数必须包含至少一个自定义类类型或枚举类型,不能全部为内置类型。
- 优先级和结合性:运算符的优先级和结合性在重载后保持不变,例如在表达式
a == b + c
中,加法运算符的优先级高于等于运算符。 - 操作数个数不变:运算符重载时,其操作数的个数应保持不变。
- 默认参数限制:运算符重载时,不能设置默认参数,因为设置默认值实际上改变了操作数的个数。
- 短路求值特性:对于逻辑与(
&&
)和逻辑或(||
)运算符,重载后不再具备短路求值特性,必须在进入函数体之前完成所有参数的计算,因此不推荐重载这些运算符。 - 不存在的运算符:不能创造一个不存在的运算符,如
@
、$
、、
。
# 运算符重载的形式
运算符重载可以通过以下三种形式实现:
- 友元函数:通过定义友元函数来重载运算符,这样可以访问类的私有成员。
- 普通函数:将运算符重载定义为一个普通的全局函数。
- 成员函数:将运算符重载定义为类的成员函数,这样可以更自然地与类的行为集成。
# +
运算符重载
需求:实现一个复数类,复数分为实部和虚部,重载 +
运算符,使其能够处理两个复数之间的加法运算(实部加实部,虚部加虚部)
# 友元函数实现
我们可以定义一个普通函数 add
,它接收两个 Complex
对象作为参数,并返回它们的和。由于需要访问 Complex
类的私有成员,我们可以将这个函数声明为 Complex
类的友元。
class Complex { | |
private: | |
double _real; // 实部 | |
double _imag; // 虚部 | |
public: | |
Complex(double r, double i) : real(r), imag(i) {} | |
friend Complex add(const Complex &lhs, const Complex &rhs); | |
}; | |
Complex add(const Complex &lhs, const Complex &rhs) { | |
return Complex(lhs.real + rhs.real, lhs.imag + rhs.imag); | |
} | |
void test0() { | |
Complex cx(1, 2); | |
Complex cx2(3, 4); | |
Complex cx3 = add(cx, cx2); | |
} |
为了使代码更直观和简洁,我们可以定义一个运算符重载函数 operator+
,这样可以直接使用 +
运算符来完成两个 Complex
对象的加法运算。
#include <iostream> | |
class Complex { | |
public: | |
Complex(double real, double imag) : _real(real), _imag(imag) {} | |
// 运算符重载:友元函数 | |
friend Complex operator+(const Complex &lhs, const Complex &rhs); | |
// 打印函数 | |
void print() const { | |
std::cout << "(" << _real << ", " << _imag << "i)" << std::endl; | |
} | |
private: | |
double _real; | |
double _imag; | |
}; | |
// 加法运算符重载 | |
Complex operator+(const Complex &lhs, const Complex &rhs) { | |
return Complex(lhs._real + rhs._real, lhs._imag + rhs._imag); | |
} | |
int main() { | |
Complex cx1(1.0, 2.0); | |
Complex cx2(3.0, 4.0); | |
Complex cx3 = cx1 + cx2; // 使用重载的 + 运算符 | |
cx3.print(); // 输出: (4, 6i) | |
return 0; | |
} |
运算符重载的步骤
- 确定返回类型:加法运算的返回值应该是一个临时的
Complex
对象,因此返回类型为Complex
。 - 定义函数名:使用
operator+
作为函数名。 - 补充参数列表:加法运算有两个操作数,分别是两个
Complex
对象。由于加法操作不改变操作数的值,可以使用const
引用作为形参。 - 完成函数体:直接调用
Complex
构造函数创建一个新的对象作为返回值。
友元函数的优势
对于不会修改操作数值的运算符(如加号),倾向于采用友元函数的方式重载,因为这样可以访问类的私有成员,同时保持操作数的不变性。
# 普通函数实现
如果需要在类外的普通函数中访问类的私有成员,可以提供公共的访问器(getter)函数。然而,这种方法通常会破坏封装性,因为它公开了类的内部细节。
实际工作中不推荐使用,因为这样做几乎完全失去了对私有成员的保护。
#include <iostream> | |
class Complex { | |
public: | |
Complex(double real, double image) : _real(real), _image(image) {} | |
// Getter functions | |
double getReal() const { return _real; } | |
double getImage() const { return _image; } | |
// 友元声明,以便下面的运算符重载函数访问私有成员 | |
friend Complex operator+(const Complex & lhs, const Complex & rhs); | |
private: | |
double _real; | |
double _image; | |
}; | |
// 运算符重载函数 | |
Complex operator+(const Complex & lhs, const Complex & rhs) { | |
return Complex(lhs.getReal() + rhs.getReal(), | |
lhs.getImage() + rhs.getImage()); | |
} | |
int main() { | |
Complex c1(1, 2), c2(3, 4); | |
Complex c3 = c1 + c2; // 使用运算符重载进行加法 | |
std::cout << "c3: (" << c3.getReal() << ", " << c3.getImage() << "i)" << std::endl; | |
return 0; | |
} |
# 成员函数实现
除了使用友元函数,运算符重载函数也可以定义为 Complex
类的成员函数。这种方式下,加法运算符的左操作数实际上是调用对象本身,因此在参数列表中只需要右操作数。
#include <iostream> | |
class Complex { | |
public: | |
Complex(double real, double imag) : _real(real), _image(imag) {} | |
// 成员函数实现加法运算符重载 | |
// 会有一个默认的 this 指针作为第一个参数 | |
Complex operator+(const Complex & rhs) const { | |
return Complex(_real + rhs._real, _image + rhs._image); | |
} | |
private: | |
double _real; | |
double _image; | |
}; | |
int main() { | |
Complex cp1(1, 2); | |
Complex cp2(3, 4); | |
Complex cp3 = cp1 + cp2; // 调用 cp1.operator+(cp2) | |
std::cout << "cp3: (" << cp3._real << ", " << cp3._image << "i)" << std::endl; | |
return 0; | |
} |
虽然语法上允许在 operator+
中实现减法运算,但这与人们的直觉思维不符,可能会引起不必要的混乱。因此,除非有特别的理由,应尽量使重载的运算符与其内置的语义保持一致。
# +=
运算符重载
- 确定重载方式:选择成员函数方式重载,因为
+=
运算符会修改左侧操作数的值。 - 确定函数的返回值:
+=
运算符重载函数应返回对当前对象的引用,以便支持链式操作。 - 编写函数名:使用
operator+=
作为函数名。 - 补充参数列表:需要一个参数,即右侧操作数,通常以
const
引用的形式传递。 - 完成函数体:实现加法逻辑,并更新当前对象的状态。
以下是 Complex
类的 +=
运算符重载实现:
#include <iostream> | |
class Complex { | |
public: | |
Complex(double real, double imag) : _real(real), _image(imag) {} | |
// 加法运算符重载(成员函数) | |
Complex& operator+=(const Complex& rhs) { | |
_real += rhs._real; | |
_image += rhs._image; | |
return *this; // 返回当前对象的引用 | |
} | |
private: | |
double _real; | |
double _image; | |
}; | |
int main() { | |
Complex c1(1, 2); | |
Complex c2(3, 4); | |
c1 += c2; // 使用 += 运算符 | |
std::cout << "c1 += c2: " << c1 << std::endl; | |
return 0; | |
} |
注意事项:
- 返回引用:
+=
运算符重载应返回对当前对象的引用,以支持链式操作。 - 修改对象:
+=
运算符重载会修改左侧操作数的状态。
# 重载形式的选择
运算符重载的选择
在 C++ 中,运算符重载可以通过成员函数或友元函数实现。选择哪种形式取决于运算符的特性:
- 友元函数:适用于不会修改操作数的运算符。友元函数可以访问类的私有成员,适合需要读取但不需要修改操作数的场景。
- 成员函数:适用于会修改操作数的运算符。成员函数可以直接访问和修改调用对象的状态,适合需要改变操作数值的场景。
特定运算符的重载形式
- 赋值 (
=
)、下标 ([]
)、调用 (()
)、成员访问 (->
)、成员指针访问 (->*
):这些运算符必须通过成员函数形式重载,因为它们需要直接访问或修改对象的状态。 - 递增 (
++
)、递减 (--
)、解引用 (*
):与给定类型密切相关的运算符,通常应该是成员函数形式重载,因为它们通常需要改变对象的状态。 - 对称性运算符(如 相等性 (
==
)、不等性 (!=
)、位运算符 (<<
)):这些运算符可能需要转换任意一端的运算对象,通常应该是友元形式重载,以保持对称性和灵活性。
# ++
运算符重载
自增运算符 ( ++
) 有两种形式:前置和后置。它们在语义上有细微差别,前置版本直接返回增加后的值,而后置版本返回增加前的值。
前置 ++
:
- 返回增加后的引用。
- 常用于连续增加操作。
后置 ++
:
- 返回增加前值的副本。
- 常用于单独的增加操作。
前置形式和后置形式都是只有一个操作数(本对象),参数完全相同的情况下,只有返回类型不同不能构成重载。前置形式和后置形式的区分只能通过设计层面人为地加上区分。
class Complex { | |
public: | |
Complex(double real, double imag) : _real(real), _image(imag) {} | |
// 前置自增运算符重载 | |
Complex& operator++() { | |
std::cout << "Prefix ++" << std::endl; | |
++_real; | |
++_image; | |
return *this; | |
} | |
// 后置自增运算符重载 | |
// 参数列表中多加一个 int 表示后置 ++ | |
Complex operator++(int) { | |
std::cout << "Postfix ++" << std::endl; | |
Complex temp(*this); | |
++_real; | |
++_image; | |
return temp; | |
} | |
private: | |
double _real; | |
double _image; | |
}; |
# []
运算符重载
需求:定义一个 CharArray
类,模拟 char
数组,需要通过下标访问运算符能够对对应下标位置字符进行访问。
-
分析
[ ]
运算符重载函数的返回类型,因为通过下标取出字符后可能进行写操作,需要改变CharArray
对象的内容,所以应该用char
引用; -
[ ]
运算符的操作数有两个,一个是CharArray
对象,一个是下标数据,ch [0]
的本质是ch.operator[](0)
;
函数体实现需要考虑下标访问越界情况,若未越界则返回对应下标位置的字符,若越界返回终止符。
class CharArray { | |
public: | |
CharArray(const char *pstr) | |
: _capacity(strlen(pstr) + 1), _data(new char[_capacity]()) { | |
strcpy(_data, pstr); | |
} | |
~CharArray() { | |
if (_data) { | |
delete[] _data; | |
_data = nullptr; | |
} | |
} | |
// "hello" 来创建 | |
// capacity = 6 | |
// 下标只能取到 4 | |
char &operator[](size_t idx) { | |
if (idx < _capacity - 1) { | |
return _data[idx]; | |
} else { | |
cout << "out of range" << endl; | |
static char nullchar = '\0'; | |
return nullchar; | |
} | |
} | |
void print() const { | |
cout << _data << endl; | |
} | |
private: | |
size_t _capacity; | |
char *_data; | |
}; | |
CharArray ca("hello"); | |
ca[0]; |
如果要禁止 CharArray
对象通过下标访问修改字符数组中的元素,应该怎么办?
要禁止 CharArray
对象通过下标访问修改字符数组中的元素,可以将 operator[]
重载为返回一个对字符的常量引用( const char&
),而不是一个对字符的引用( char&
)。这样,当用户尝试通过下标访问来修改字符时,编译器将会报错,因为他们不能修改一个常量引用。
#include <iostream> | |
#include <cstring> | |
class CharArray { | |
public: | |
CharArray(const char *pstr) : _capacity(strlen(pstr) + 1), _data(new char[_capacity]()) { | |
strcpy(_data, pstr); | |
} | |
~CharArray() { | |
delete[] _data; | |
} | |
// 返回常量引用 | |
const char &operator[](size_t idx) const { | |
if (idx < _capacity - 1) { | |
return _data[idx]; | |
} else { | |
std::cout << "Out of range" << std::endl; | |
static const char nullchar = '\0'; | |
return nullchar; | |
} | |
} | |
void print() const { | |
std::cout << _data << std::endl; | |
} | |
private: | |
size_t _capacity; | |
char *_data; | |
}; | |
int main() { | |
CharArray ca("hello"); | |
std::cout << "ca[0]: " << ca[0] << std::endl; // 输出字符 'h' | |
//ca [0] = 'x'; // 这将导致编译错误,因为我们不能修改常量引用 | |
return 0; | |
} |
# 输入输出流运算符重载
# 输出流运算符重载
在之前的例子中,我们如果想打印一个对象时,常用的方法是通过定义一个 print
成员函数来完成,但使用起来不太方便。我们希望打印一个对象,与打印一个整型数据在形式上没有差别 (如下例子),那就必须要重载 <<
运算符。
需求:
我们希望 Complex
类型的对象能够像内置类型一样,使用输出流运算符 <<
进行输出。
分析:
- 输出流运算符
<<
通常有两个操作数:左侧是输出流对象,右侧是我们想要输出的对象。如果我们将<<
运算符定义为Complex
类的成员函数,那么Complex
对象将作为左操作数,这与内置类型的使用方式不符。因此选择使用友元函数来重载<<
运算符。 - 当我们使用
cout << cx
时,我们期望表达式的结果是cout
对象本身,以便可以链式调用其他输出操作。由于cout
是一个全局对象,我们不能返回它的副本,因此重载函数的返回类型应为ostream &
。 - 在参数列表中,第一个参数是输出流对象(例如
cout
),我们应明确其类型和参数名;第二个参数是Complex
对象,由于我们不会修改它,所以使用const
引用。 - 在函数体内,我们将
Complex
对象的信息输出到输出流os
,并且不添加endl
,以避免不必要的换行。
class Point { | |
public: | |
//... | |
friend ostream & operator<<(ostream & os, const Point & rhs); | |
private: | |
int _x; | |
int _y; | |
}; | |
ostream & operator<<(ostream &os, const Point &rhs) { | |
os << "(" << rhs._x << "," << rhs._y << ")"; | |
return os; | |
} | |
void test() { | |
Point pt(1, 2); | |
cout << pt << endl; | |
// 本质形式: operator << (cout, pt) << endl; | |
} |
为了和内置类型的使用方式保持一致,输出流运算符重载采用友元形式。
如果采用成员形式进行运算符重载,那么自定义类型对象必然会作为第一个参数。
c1 << cout; | |
c1.operator << (cout); |
# 输入流运算符重载
需求:对于 Complex
对象,希望像内置类型数据一样,使用输入流运算符可以对其进行输入
以下是 Complex
类的输入流运算符重载实现,包括合法性检查的功能:
class Complex { | |
public: | |
//... | |
friend istream & operator>>(istream & is, Complex & rhs); | |
private: | |
int _real; | |
int _image; | |
}; | |
istream & operator>>(istream &is, Complex &rhs) { | |
is >> rhs._real; | |
is >> rhs._image; | |
return is; | |
} |
如果不想分开输出实部和虚部,也可以直接连续输入,空格符、换行符都能作为分隔符
istream & operator>>(istream &is, Point &rhs) { | |
is >> rhs._x >> rhs._y; | |
return is; | |
} |
还有个问题需要考虑,使用输入流时需要判断是否是合法输入
可以封装一个函数判断接收到的是合法的 int
数据,在 >>
运算符重载函数中调用。
void inputInt(istream &is, int &number) { | |
cout << "请输入一个 int 型数据" << endl; | |
while (is >> number, !is.eof()) { | |
if (is.bad()) { | |
cout << "istream has broken" << endl; | |
return; | |
} else if (is.fail()) { | |
is.clear(); | |
is.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); | |
cout << "请输入一个 int 型数据" << endl; | |
} else { | |
// 正常输入的情况 */ | |
break; | |
} | |
} | |
} | |
istream &operator>>(istream &is, Complex &rhs) { | |
cout << "请输入实部:" << endl; | |
/* is >> rhs._real; */ | |
inputInt(is, rhs._real); | |
cout << "请输入虚部:" << endl; | |
/* is >> rhs._image; */ | |
inputInt(is, rhs._image); | |
return is; | |
} |
# 成员访问运算符
成员访问运算符包括 .
和 ->
,其中 .
运算符是不能重载的, ->
运算符是可以重载的。
箭头访问运算符 ->
和解引用运算符 *
,它们是指针操作最常用的两个运算符。
箭头运算符只能以成员函数的形式重载,其返回值必须是一个指针或者重载了箭头运算符的对象。
# 两层结构下的使用
例子:建立一个双层的结构, MiddleLayer
含有一个 Data*
型的数据成员
#include <iostream> | |
using std::cout; | |
using std::endl; | |
class Data { | |
public: | |
Data() { | |
cout << "Data()" << endl; | |
} | |
~Data() { | |
cout << "~Data()" << endl; | |
} | |
int getData()const { return _data;} | |
private: | |
int _data = 10; | |
}; | |
class MiddleLayer { | |
public: | |
MiddleLayer(Data *p) | |
: _pdata(p) { | |
cout << "MiddleLayer(Data*)" << endl; | |
} | |
~MiddleLayer() { | |
cout << "~MiddleLayer()" << endl; | |
if (_pdata) { | |
delete _pdata; | |
_pdata = nullptr; | |
} | |
} | |
private: | |
Data *_pdata; | |
}; |
Data *
原生指针的用法如下,需要关注堆空间资源的回收
Data * p = new Data(); | |
p->getData(); | |
(*p).getData(); | |
delete p; | |
p = nullptr; |
如果用这种方式创建 MiddleLayer
对象,我们发现不需要手动 delet pdata
,并没有发生内存泄露,反而手动 delet pdata
后会有 double free
的问题。
void test0() { | |
Data *pdata = new Data(); | |
cout << pdata->getData() << endl; | |
MiddleLayer ml(pdata); | |
//ml 底层的指针接管堆上的 Data 对象后 | |
// 可以完成自动回收 | |
// 但是如果仍然手动 delete,会有 double free 问题 | |
/* delete pdata; */ | |
} |
因为 MiddleLayer
对象实际上对堆上 Data
对象形成了接管。
当然,也可以让 Middlelayer
对象自己管理一个 Data
对象。
Data *p1 = new Data(); | |
MiddleLayer ml(new Data()); |
需求:希望实现一个这样的效果,创建 MiddleLayer
对象 ml
,让 ml
对象可以使用箭头运算符去调用 Data
类的成员函数 getData
MiddleLayer ml(new Data); | |
cout << ml->getData() << endl; |
这个需求很合理,因为 MiddleLayer
的数据成员只有一个 Data*
类型的指针
箭头运算符无法应对 MiddleLayer
对象,那么可以定义箭头运算符重载函数。
- 首先不用考虑重载形式,箭头运算符必须以成员函数形式重载;
- 然后考虑返回类型,返回值需要使用箭头运算符调用 getData 函数,而原生的用法只有
Data *
才能这么用,所以返回值应该是一个Data*
,此时应该直接返回_pdata
;
Data* operator->(){ | |
return _pdata; | |
} |
解引用运算符应该如何重载能够实现同样的效果呢?直接使用 MiddleLayer
对象模仿 Data*
指针去访问 getData
函数。
class MiddleLayer { | |
public: | |
MiddleLayer(Data *p) | |
: _pdata(p) { | |
cout << "MiddleLayer(Data*)" << endl; | |
} | |
~MiddleLayer() { | |
cout << "~MiddleLayer()" << endl; | |
if (_pdata) { | |
delete _pdata; | |
_pdata = nullptr; | |
} | |
} | |
Data *operator->() { | |
return _pdata; | |
} | |
Data &operator*() { | |
return *_pdata; | |
} | |
private: | |
Data *_pdata; | |
}; | |
void test1() { | |
Data *pdata = new Data(); | |
cout << pdata->getData() << endl; | |
cout << (*pdata).getData() << endl; | |
delete pdata; | |
// 规范使用 | |
MiddleLayer ml(new Data()); | |
// 第一个箭头与 operator 连在一起作为函数名 | |
// 第二个箭头是编译器自动加上的箭头, | |
// 表示原生的成员访问运算符 | |
// | |
// 以往的运算符重载案例中,比如 cx1 += cx2; | |
// 本质是 cx1.operator+=(cx2); | |
// ++cx1; | |
// 本质是 cx1.operator++() | |
// 箭头运算符相比而言,比较特别 | |
/* cout << (ml.operator->())->getData() << endl; */ | |
cout << ml->getData() << endl; | |
// 本质 | |
/* cout << ml.operator*().getData() << endl; */ | |
cout << (*ml).getData() << endl; | |
// 智能指针的雏形 | |
// 利用局部对象的生命周期来管理堆空间资源 | |
//ml 不是一个指针,本质上是一个对象 | |
// 这个对象管理的堆空间可以借助析构函数进行自动回收 | |
// 有了相应的运算符重载函数的支持后 | |
// 这个对象可以像原生的指针一样使用 | |
} |
当我们完成了以上的需求后,还有一件 “神奇” 的事情,使用的语句中有 new
没有 delete
,但是检查发现并没有内存泄漏。
原因: MiddleLayer
类通过重载箭头运算符 ->
和解引用运算符 *
,使得它的行为类似于指针,也可以像指针一样进行使用,但是这个对象在栈帧结束时会自动销毁,自动调用析构函数回收了它的数据成员所申请的堆空间。
实际上,这就是智能指针的雏形:其思想就是通过对象的生命周期来管理资源。
# 三层结构下的使用
如果结构再加一层,引入一个 ThirdLayer
类。
class ThirdLayer { | |
public: | |
ThirdLayer(MiddleLayer *pml) | |
: _pml(pml) { | |
cout << "ThirdLayer(MiddleLayer*)" << endl; | |
} | |
~ThirdLayer() { | |
cout << "~ThirdLayer()" << endl; | |
if (_pml) { | |
delete _pml; | |
_pml = nullptr; | |
} | |
} | |
private: | |
MiddleLayer *_pml; | |
}; |
创建 ThirdLayer
对象时注意避免这样的错误:
// 错误的创建方式,让 tl 对象接管了栈上的 MiddleLayer 对象 | |
// 在执行析构时会发生段错误 | |
Data *p1 = new Data(); | |
MiddleLayer ml(pl); | |
ThirdLayer tl(&ml); |
注意:应该让 ThirdLayer
底层的指针管理一个堆上的 MiddleLayer
对象。
希望实现如下使用方式,思考一下应该如何对 ThirdLayer
进行对应的运算符重载
ThirdLayer tl(new MiddleLayer(new Data)); | |
cout << tl->getData() << endl; | |
cout << (*(*tl)).getData() << endl; |
在 ThirdLayer
类中定义这两个成员函数:
// 有 MiddleLayer 重载的 operator-> 函数作为铺垫 | |
// 这里只需要返回 MiddleLayer 对象即可 | |
MiddleLayer &operator->() { | |
return *_pml; | |
} | |
// 一步一步解引用 | |
MiddleLayer &operator*() { | |
return *_pml; | |
} |
箭头运算符的使用:
// MiddleLayer 中定义了 operator-> 作为铺垫 | |
// 使得 MiddleLayer 对象能够直接使用 -> 去访问 Data 的成员 | |
cout << ((tl.operator->()).operator->())->getData() << endl; | |
// 第一个 operator-> 是 ThirdLayer 类的成员函数 | |
// 执行完的返回值是一个 MiddleLayer 对象 | |
// 由于 MiddleLayer 类中已经对 -> 运算符进行过重载 | |
// 所以 MiddlerLayer 对象可以直接用 ->getData () | |
// | |
// 第二个 operator-> 是 MiddleLayer 类的成员函数 | |
cout << (tl.operator->())->getData() << endl; | |
cout << tl->getData() << endl; |
解引用运算符的使用:
ThirdLayer tl(new MiddleLayer(new Data())); | |
// 一步一步解引用 | |
// 前一个 operator* 是 ThirdLayer 的成员函数 | |
// 后一个 operator* 是 MiddleLayer 的成员函数 | |
cout << tl.operator*().operator*().getData() << endl; | |
cout << (*(*tl)).getData() << endl; |
如果解引用的使用也希望和箭头运算符一样,一步到位,又该如何实现?
ThirdLayer tl(new MiddleLayer(new Data)); | |
cout << (*tl).getData() << endl; |
如下三种 return 的形式都可以实现:
Data &operator*() { | |
return *(*_pml); | |
// 本质 | |
return (*_pml).operator*(); | |
// 需要友元的支持 | |
return *((*_pml)._pdata); | |
} |
// 一步到位解引用 | |
cout << tl.operator*().getData() << endl; | |
cout << (*tl).getData() << endl; |
# 内存分析
三层的结构比较复杂,我们可以通过内存图的方式进行分析。
ThirdLayer
对象的创建
ThirdLayer tl(new MiddleLayer(new Data())); |
实际上的内存结构如图:
创建过程:
- ** 创建
ThirdLayer
对象:** 当创建一个ThirdLayer
对象时,首先调用ThirdLayer
的构造函数。 - ** 创建
MiddleLayer
对象:** 在ThirdLayer
构造函数的参数初始化过程中,会创建一个MiddleLayer
对象。 - ** 创建
Data
对象:** 在创建MiddleLayer
对象的过程中,会创建一个Data
对象。 - 初始化指针数据成员:
- 首先,
Data
对象被构造完成。 - 然后,
MiddleLayer
的指针数据成员_pdata
被初始化为指向新创建的Data
对象。 - 最后,
ThirdLayer
的指针数据成员_pml
被初始化为指向新创建的MiddleLayer
对象。
- 首先,
销毁过程:
- 销毁
ThirdLayer
对象:当ThirdLayer
对象的生命周期结束时,首先调用ThirdLayer
的析构函数。 - 销毁
MiddleLayer
对象:- 在
ThirdLayer
的析构函数中,会调用delete
来释放MiddleLayer
对象。 - 这将首先调用
MiddleLayer
的析构函数。
- 在
- 销毁
Data
对象:- 在
MiddleLayer
的析构函数中,会调用delete
来释放Data
对象。 - 这将调用
Data
的析构函数。
- 在
# 可调用实体
当我们探讨 “调用” 这一术语时,首先映入脑海的是 普通函数 和 函数指针。随着对面向对象编程的深入了解,我们又发现了 成员函数 的存在。这些都可以被统称为 可调用实体。实际上,为了适应多样化的编程需求,C++ 还引入了其他类型的可调用实体,它们通常通过运算符的重载来实现。
普通函数的一个关键特性是它们不具备状态保持能力。一旦执行结束,其所在的函数栈空间就会被回收,导致无法保留执行过程中的状态信息。这限制了它们在需要状态持久化的场景中的应用。相比之下,类与对象的引入为我们提供了一种机制,使得状态信息得以在对象的生命周期内得以保持。只要对象不被销毁,其状态就能持续存在。
# 函数对象
首先,我们有一个类 FunctionObject
,我们希望这个类的对象能够像函数一样被调用。
class FunctionObject { | |
// ... | |
}; | |
void test0() { | |
FunctionObject fo; | |
fo(); // 让对象像函数一样被调用 | |
} |
这段代码看起来有些奇怪,因为对象 fo
本身并不是一个函数。为了解决这个问题,我们需要重载函数调用运算符 ()
。
函数调用运算符必须以成员函数的形式进行重载。
class FunctionObject { | |
public: | |
void operator()() { | |
std::cout << "FunctionObject operator()()" << std::endl; | |
} | |
}; | |
void test0() { | |
FunctionObject fo; | |
fo(); // 现在可以正常调用 | |
} |
在定义 operator()
时,第一对小括号总是空的,表示我们正在定义的运算符名称,第二对小括号是函数参数列表,与普通函数的参数列表完全相同。
与其他运算符不同,函数调用运算符的参数可以根据需要来确定,并不固定。
class FunctionObject { | |
public: | |
// 第一个 () 与 operator 连在一起作为函数名 | |
// 第二个 () 代表该函数的参数列表的括号 | |
void operator()() { | |
std::cout << "FunctionObject operator()()" << std::endl; | |
} | |
int operator()(int x, int y) { | |
std::cout << "operator()(int,int)" << std::endl; | |
return x + y; | |
} | |
}; | |
void test0() { | |
FunctionObject fo; | |
std::cout << fo() << std::endl; // 调用无参版本 | |
std::cout << fo(5, 6) << std::endl; // 调用有参版本 | |
} |
重载了函数调用运算符的类的对象称为函数对象。由于参数列表可以随意扩展,所以可以有很多重载形式(对应了普通函数的多种重载形式)。
函数对象的优点:
- 携带状态:函数对象可以封装自己的数据成员和成员函数,具有更好的面向对象特性。
- 记录调用次数:可以记录函数对象被调用的次数,而普通函数只能通过全局变量实现(全局变量不够安全)。
函数对象是 STL(标准模板库)的六大组件之一,可以做很多定制化的行为。
# 函数指针
在 C++ 中,函数指针允许我们将函数当作对象来处理。定义函数指针时,需要明确指出指针指向的函数类型,包括返回类型和参数类型。
void print(int x){ | |
std::cout << "print:" << x << std::endl; | |
} | |
void display(int x){ | |
std::cout << "display:" << x << std::endl; | |
} | |
int main(void){ | |
// 省略形式 | |
void (*p)(int) = print; | |
p(4); | |
p = display; | |
p(9); | |
// 完整形式 | |
void (*p2)(int) = &print; | |
(*p2)(4); | |
p2 = &display; | |
(*p2)(9); | |
return 0; | |
} |
在这个例子中, p
和 p2
是函数指针,定义函数指针 p
后,可以指向 print
函数,也可以再指向 `display 函数,并通过函数指针调用函数。
我们可以将函数指针抽象为一个类,这个类的对象就是特定类型的函数指针。这样的抽象有助于我们更好地管理和使用函数指针。
p
和 p2
可以抽象出一个函数指针类型 void(*)(int)
。
该类型为逻辑类型,不能在代码中直接以这种形式写出。
以前我们使用 typedef
可以定义类型别名,这段程序中函数指针 p
、 p2
的类型是 void(*)(int)
,但是 C++ 中是没有这个类的。
这里,我们使用 typedef
定义了一个新类型 Function
,它代表一个指向接受一个 int
参数并返回 void
的函数的指针。
typedef void(*Function)(int); |
使用 Function
类型,我们可以更简洁地定义和使用函数指针。
void print(int x){ | |
cout << "print:" << x << endl; | |
} | |
void display(int x){ | |
cout << "display:" << x << endl; | |
} | |
void show(int x,int y){ | |
cout << "show:" << 100 << endl; | |
} | |
typedef int INT; | |
typedef void (*Function)(int); | |
void test0(){ | |
// 定义函数指针时, | |
// 要确定这个指针所指向的函数的返回类型和参数信息 | |
// 简略形式 | |
Function f | |
f = print; | |
f(100); | |
f = display; | |
f(200); | |
return 0; | |
} |
在这个例子中, f
是一个 Function
类型的变量,它可以指向任何符合 Function
类型的函数。
# 成员函数指针
在 C++ 中,成员函数指针是指向类成员函数的指针。与普通函数指针相比,成员函数指针需要额外的信息,即它们所属的类。这是因为成员函数总是与某个对象关联的。
定义成员函数指针时,需要指定成员函数的返回类型、参数类型以及它所属的类。
class FFF { | |
public: | |
void print(int x) { | |
std::cout << "FFF::print:" << x << std::endl; | |
} | |
void display(int x) { | |
std::cout << "FFF::display:" << x << std::endl; | |
} | |
}; |
定义一个函数指针要明确指针指向的函数的返回类型、参数类型,那么定义一个成员函数指针还需要确定的是这个成员函数是哪个类的成员函数(类的作用域)
与普通函数指针不一样的是,成员函数指针的定义和使用都需要使用完整写法,不能使用省略写法,定义时要完整写出指针声明,使用时要完整写出解引用(解出成员函数后接受参数进行调用)。
另外,成员函数需要通过对象来调用,成员函数指针也需要通过对象来调用。
// 定义成员函数指针时需要确定其指向的成员函数 | |
// 类的名称、返回类型、参数情况 | |
// 指向成员函数和调用成员函数时,需要用完整形式 | |
void (FFF::*p)(int) = &FFF::print; | |
FFF ff; | |
(ff.*p)(4); // 通过对象调用成员函数指针 |
这里, p
是一个指向 FFF
类成员函数的指针,它指向 print
函数。通过对象 ff
调用成员函数指针。
为了简化成员函数指针的使用,可以使用 typedef
来定义成员函数指针的类型。
typedef void (FFF::*MemberFunction)(int); // 定义成员函数类型 MemberFunction | |
MemberFunction mf = &FFF::print; // 定义成员函数指针 | |
FFF fff; | |
(fff.*mf)(15); // 通过对象调用成员函数指针 |
.*
:成员指针运算符的第一种形式,用于通过对象调用成员函数指针。->
:成员指针运算符的第二种形式,用于通过指针调用成员函数指针。
FFF *fp = new FFF(); | |
(fp->*mf)(65); // 通过指针调用成员函数指针 |
- 回调函数:可以将成员函数指针作为参数传递给其他函数,使其他函数能够在特定条件下调用该成员函数。
- 事件处理:将成员函数指针存储在事件处理程序中,以便在特定事件发生时调用相应的成员函数。
- 多态性:通过将成员函数指针存储在基类指针中,可以实现多态性,在运行时能够调用相应的成员函数。
# 空指针的使用
示例代码
class Bar{ | |
public: | |
void test0() { std::cout << "Bar::test0()" << std::endl; } | |
void test1(int x) { std::cout << "Bar::test1(): " << x << std::endl; } | |
void test2() { std::cout << "Bar::test2(): " << _data << std::endl; } | |
int _data = 10; | |
}; | |
void test0(){ | |
Bar * fp = nullptr; | |
fp->test0(); // 可以调用 | |
fp->test1(3); // 可以调用 | |
fp->test2(); // 错误 | |
} |
在这个例子中,我们定义了一个类 Bar
,它有三个成员函数: test0()
、 test1(int x)
和 test2()
。然后,我们创建了一个空指针 fp
,并尝试调用这些成员函数。
fp->test0();
可以成功调用,因为test0()
不访问任何数据成员。fp->test1(3);
也可以成功调用,因为它同样不访问任何数据成员。fp->test2();
会导致错误,因为它试图访问数据成员_data
。
在 C++ 中,非虚成员函数可以在没有对象上下文的情况下调用。这意味着即使指针是空的,编译器也能正确地调用这些函数。但是,如果成员函数试图访问对象的数据成员,就会出错,因为空指针没有指向有效的对象。
在 C++ 中,我们可以将以下实体视为可调用实体:
- 普通函数
- 函数指针
- 成员函数
- 成员函数指针
- 函数对象
这些可调用实体提供了多种调用函数的方式,但使用时需要注意它们的上下文依赖性。特别是在使用空指针调用成员函数时,要确保成员函数不会访问对象的数据成员,以避免未定义的行为。
虽然技术上可以使用空指针调用非虚成员函数,但这并不是一个好的编程习惯。在实际编程中,应该避免这样做,以确保代码的安全性和可读性。
# 类型转换函数
在 C++ 中,类型转换是一个强大的特性,允许我们在不同的类型之间进行转换。这包括从基本数据类型转换到自定义类型,以及从自定义类型转换到其他类型。让我们详细探讨这两种转换方式。
# 由其他类型向自定义类型转换
这种转换通常由类的构造函数实现。如果类中定义了合适的构造函数,编译器可以自动将其他类型的值转换为自定义类型的对象。这种转换一般称为 隐式转换。
示例代码:
class Point { | |
public: | |
int _ix, _iy; | |
Point(int x) { _ix = _iy = x; } | |
// ... | |
}; | |
Point pt = 1; // 隐式转换 | |
// 等价于 Point pt = Point (1); |
在这个例子中, Point
类有一个接受单个 int
参数的构造函数。编译器使用这个构造函数将 int
类型的值 1
转换为 Point
类型的对象。
防止隐式转换:
为了防止不希望的隐式转换,可以在构造函数前加上 explicit
关键字。
class Point { | |
public: | |
explicit Point(int x) { _ix = _iy = x; } | |
// ... | |
}; |
# 由自定义类型向其他类型转换
这种转换可以通过类型转换函数实现。类型转换函数是一种特殊的成员函数,其形式固定,没有返回类型和参数,且必须返回目标类型的值。
类型转换函数的特征:
- 必须是成员函数。
- 没有返回类型。
- 没有参数。
- 必须返回目标类型的值。
# 自定义类型向内置类型转换
示例代码:
class Point { | |
public: | |
int _ix, _iy; | |
Point(int x, int y) : _ix(x), _iy(y) {} | |
operator int() { | |
std::cout << "operator int()" << std::endl; | |
return _ix + _iy; | |
} | |
// ... | |
}; | |
Point pt(1, 2); | |
int a = pt; // 自定义类型向内置类型转换 | |
std::cout << a << std::endl; |
在这个例子中, Point
类定义了一个类型转换函数 operator int()
,允许将 Point
类型的对象转换为 int
类型的值。
# 自定义类型之间的转换
自定义类型不仅可以转换为内置类型,还可以转换为其他自定义类型。关键在于将类型转换函数定义在哪个类中。
示例代码:
class Complex { | |
public: | |
double _real, _image; | |
Complex(double r, double i) : _real(r), _image(i) {} | |
operator Point() { | |
std::cout << "operator Point()" << std::endl; | |
return Point(static_cast<int>(_real), static_cast<int>(_image)); | |
} | |
// ... | |
}; | |
Point pt; | |
Complex cx(3, 4); | |
pt = cx; // 自定义类型向自定义类型转换 |
在这个例子中, Complex
类定义了一个类型转换函数 operator Point()
,允许将 Complex
类型的对象转换为 Point
类型的对象。
# 转换的优先级
- 赋值运算符函数:如果存在,将优先使用。
- 类型转换函数:如果没有赋值运算符函数,将使用类型转换函数。
- 隐式转换(特殊的构造函数):如果前两者都不存在,将使用隐式转换。
优先级: 赋值运算符函数 > 类型转换函数 > 隐式转换
用一个 Complex
对象给 Point
对象赋值
编译器会先到 Point 类中找有没有合适的赋值运算符函数( Point& operator =(const Complex & rhs)
),如果有的话,编译器就知道了怎样去进行赋值操作,不需要任何的转换;如果没有,编译器再到 Complex
类中找有没有合适的类型转换函数( operator Point()
),如果有的话,就可以让 cx
对象调用类型转换函数,返回一个临时的 Point
对象,然后再用这个临时的 Point
对象给 pt
赋值。
如果前两种方式都行不通,编译器再会到 Point
类中找有没有合适的构造函数,可以利用一个 Complex
对象创建出一个 Point
对象(隐式转换的途径)。
cx.operator=(pt); // 优先级 1:特殊的赋值运算符函数(运算符重载的思路) | |
cx = pt.operator Complex(); // 优先级 2:类型转换函数 | |
cx = Complex(pt); // 优先级 3 :特殊构造函数(隐式转换的思路) |
# C++ 运算符优先级排序与结合性
优先级 | 运算符 | 描述 | 结合性 |
---|---|---|---|
1 | :: |
作用域解析 | 从左到右 → |
1 | a++, a-- |
后缀自增与自减 | 从左到右 → |
1 | type(), type{} |
函数风格转换 | 从左到右 → |
2 | a() |
函数调用 | 从左到右 → |
2 | a[] |
下标 | 从左到右 → |
2 | .-> |
成员访问 | 从左到右 → |
2 | ++a, --a |
前缀自增与自减 | 从右到左 ← |
2 | +a, -a |
一元加与减 | 从右到左 ← |
2 | !, - |
逻辑非和逐位非 | 从右到左 ← |
2 | (type) |
C 风格转换 | 从右到左 ← |
2 | *a |
间接 (解引用) | 从右到左 ← |
3 | &a |
取址 | 从右到左 ← |
3 | sizeof |
取大小 | 从右到左 ← |
3 | co_await |
await 表达式 (C++20) | 从右到左 ← |
3 | new, new[] |
动态内存分配 | 从右到左 ← |
3 | delete, delete[] |
动态内存释放 | 从右到左 ← |
4 | .* ,->* |
成员指针 | 从左到右 → |
5 | a*b, a/b, a%b |
乘法、除法与余数 | 从左到右 → |
6 | a+ba-b |
加法与减法 | 从左到右 → |
7 | <<, >> |
逐位左移与右移 | 从左到右 → |
8 | <=> |
三路比较运算符 (C++20 起) | 从左到右 → |
9 | <, <=, >, >= |
分别为 <与≤与> 与 ≥ 的关系运算符 | 从左到右 → |
10 | ==, != |
分别为 = 与 ≠ 的相等性运算符 | 从左到右 → |
11 | a&b |
逐位与 | 从左到右 → |
12 | ^ |
逐位异或 (互斥或) | 从左到右 → |
13 | | |
逐位或 (可兼或) | 从左到右 → |
14 | && |
逻辑与 | 从左到右 → |
15 | || |
逻辑或 | 从左到右 → |
16 | a?b:c |
三元条件 | 从右到左 ← |
16 | throw |
throw 运算符 | 从右到左 ← |
16 | co_yield |
yield 表达式 (C++20) | 从右到左 ← |
16 | = |
直接赋值 (C++ 类默认提供) | 从右到左 ← |
16 | +=, -= |
以和及差复合赋值 | 从右到左 ← |
16 | *=, /=, %= |
以积、商及余数复合赋值 | 从右到左 ← |
16 | <<=, >>= |
以逐位左移及右移复合赋值 | 从右到左 ← |
16 | &=, ^= ,|= |
以逐位与、异或及或复合赋值 | 从右到左 ← |
17 | , |
逗号 | 从左到右 → |
# 嵌套类
# 嵌套类的定义
在 C++ 中,类提供了两种作用域:类作用域和类名作用域,它们在访问控制和成员访问方面起着关键作用。
类作用域 是指类定义内部的范围。在这个作用域内定义的所有成员(包括变量、函数、类型别名等)都可以被类的所有成员函数访问。
示例:
class MyClass { | |
public: | |
void func() { | |
_b = 100; // 类的成员函数内访问 _b | |
} | |
int _b; // 类作用域内的成员变量 | |
}; |
在这个例子中, _b
是类作用域内的成员变量,可以在类的所有成员函数中访问。
类名作用域 是指可以通过类名访问的作用域。这主要用于访问类的静态成员和嵌套类型。
示例:
class MyClass { | |
public: | |
static int _a; | |
int _b; | |
}; | |
int MyClass::_a = 0; // 类名作用域内的静态成员 | |
void test0() { | |
MyClass::_a = 200; // 类外部访问 _a | |
} |
在这个例子中, _a
是一个静态成员,可以通过类名来访问。
在函数和其他类定义的外部定义的类称为 全局类,绝大多数的 C++ 类都是全局类。我们在前面定义的所有类都在全局作用域中,全局类具有全局作用域。
与之对应的,一个类 A 还可以定义在另一类 B 的定义中,这就是 嵌套类 结构。A 类被称为 B 类的 内部类,B 类被称为 A 类的 外部类。
示例:
class Line { | |
public: | |
class Point { // 嵌套类 | |
public: | |
Point(int x, int y) : _ix(x), _iy(y) {} | |
private: | |
int _ix; | |
int _iy; | |
}; | |
Line(int x1, int y1, int x2, int y2) : _pt1(x1, y1), _pt2(x2, y2) {} | |
private: | |
Point _pt1; | |
Point _pt2; | |
}; |
在这个例子中, Point
是 Line
类的内部类。
嵌套类无法在外部类的作用域外直接创建对象,需要在外部类的类名作用域内才能创建。
示例:
Point pt(1, 2); // 错误 | |
Line::Point pt2(3, 4); // 正确 |
嵌套类并不会影响外部类的存储结构。只有当外部类包含内部类的对象成员时,外部类对象的内存布局中才会包含内部类对象。
示例:
class Line { | |
public: | |
class Point { | |
public: | |
Point(int x, int y) | |
: _ix(x), _iy(y) {} | |
private: | |
int _ix; | |
int _iy; | |
}; | |
public: | |
Line(int x1, int y1, int x2, int y2) | |
: _pt1(x1, y1), _pt2(x2, y2) {} | |
private: | |
Point _pt1; | |
Point _pt2; | |
double length = 10; | |
}; |
- 如果
Line
类中没有Point
类的对象成员,sizeof(Line) = 8
- 如果
Line
类中有两个Point
类的对象成员,sizeof(Line) = 24
如果要使用输出流运算符输出 Line
对象,需要为 Point
类定义输出流运算符重载函数。
示例:
class Line { | |
/* public: */ | |
class Point { | |
public: | |
Point(int x = 2, int y = 1) | |
: _ix(x), _iy(y) { | |
cout << "Point(int,int)" << endl; | |
} | |
friend | |
ostream &operator<<(ostream &os, const Point &rhs); | |
private: | |
int _ix; | |
int _iy; | |
}; | |
public: | |
Line(int x1, int y1, int x2, int y2) | |
: _pt1(x1, y1), _pt2(x2, y2) { | |
cout << "Line(int*4)" << endl; | |
} | |
friend | |
ostream &operator<<(ostream &os, const Line::Point &rhs); | |
friend | |
ostream &operator<<(ostream &os, const Line &rhs); | |
private: | |
Point _pt1; | |
Point _pt2; | |
}; | |
// 为了用输出流运算符输出 Point 对象 | |
// 其中访问了 Point 的私有数据成员 (_ix, _iy) | |
// 所以要设为 Point 的友元 | |
// 而且在形参中不能直接使用 Point 类型名称,需要加上 Line 的类名作用域 | |
// 如果 Point 是 Line 中的私有成员类型 | |
// 还需要将这个函数声明为 Line 的友元 | |
ostream &operator<<(ostream &os, const Line::Point &rhs) { | |
os << "(" << rhs._ix << "," << rhs._iy << ")"; | |
return os; | |
} | |
// 为了用输出流运算符输出 Line 对象 | |
// 其中访问了 Line 的私有数据成员 (_ptl, _pt2) | |
// 所以要设为 Line 的友元 | |
ostream &operator<<(ostream &os, const Line &rhs) { | |
os << rhs._pt1 << "------>" << rhs._pt2; | |
return os; | |
} |
在这个例子中,我们为 Point
类定义了输出流运算符重载函数,并将其声明为 Line
类的友元函数,以便在 Line
类的外部定义。同时,我们也为 Line
类定义了输出流运算符重载函数。
# 嵌套类结构的访问权限
在 C++ 中,嵌套类(内部类)与外部类(定义内部类的类)之间可以有紧密的联系,包括相互访问对方的成员。
# 外部类对内部类的成员进行访问
外部类可以直接访问内部类的公有成员,不需要任何特殊的声明。这是因为内部类定义在外部类的范围内,所以外部类对内部类有完全的访问权限。
示例:
class Outer { | |
public: | |
class Inner { | |
public: | |
int innerValue = 42; | |
}; | |
void accessInner() { | |
Inner inner; | |
std::cout << inner.innerValue << std::endl; // 直接访问内部类的成员 | |
} | |
}; |
在这个例子中, Outer
类可以直接访问其内部类 Inner
的成员 innerValue
。
# 内部类对外部类的成员进行访问
内部类可以访问定义它的外部类的成员,就像它们是外部类的友元一样。这包括私有成员,因为内部类是定义在外部类作用域内的。
示例:
class Outer { | |
private: | |
int outerValue = 100; | |
public: | |
class Inner { | |
public: | |
void accessOuter() { | |
std::cout << outerValue << std::endl; // 访问外部类的私有成员 | |
} | |
}; | |
void accessInner() { | |
Inner inner; | |
inner.accessOuter(); // 内部类访问外部类的私有成员 | |
} | |
}; |
在这个例子中,内部类 Inner
可以直接访问外部类 Outer
的私有成员 outerValue
。
# 内部类相当于外部类的友元类
由于内部类可以访问外部类的私有成员,这相当于内部类是外部类的友元类。这种关系使得内部类可以为外部类提供额外的灵活性和封装性。
示例:
class Outer { | |
public: | |
class Inner { | |
public: | |
void performAction() { | |
// 可以访问外部类的公有、保护、私有成员 | |
outerObject.performPrivateAction(); | |
} | |
}; | |
private: | |
void performPrivateAction() { | |
std::cout << "Performing private action." << std::endl; | |
} | |
}; | |
void test() { | |
Outer outerObject; | |
Outer::Inner innerObject = outerObject.Inner(); | |
innerObject.performAction(); // 内部类访问外部类的私有成员函数 | |
} |
在这个例子中,内部类 Inner
可以访问外部类 Outer
的私有成员函数 performPrivateAction
。
总结:
- 外部类对内部类的访问:外部类可以直接访问内部类的公有成员。
- 内部类对外部类的访问:内部类可以访问外部类的公有、保护和私有成员,就像它们是外部类的友元一样。
- 内部类与外部类的关系:内部类在定义它的外部类的作用域内,因此它们之间可以有紧密的联系和相互访问。
访问成员方式 | 不依赖对象直接访问 | 类名作用域访问 | 通过对象直接访问 |
---|---|---|---|
外部类对内部类 | 无 | 内部类的静态成员 + 声明友元才 ok | 内部类的私有成员,需要声明友元 |
内部类对外部类 | 外部类的静态成员 | 外部类的静态成员 | 即使是私有成员也 ok |
# pimpl 模式
Pimpl(Pointer to Implementation)模式,也被称为编译防火墙,是一种在 C++ 中常用的设计技巧,用于隐藏类的实现细节。这种模式通过将类的具体实现移到另一个私有类中来实现,从而使得类的实现文件与头文件分离,有助于减少编译依赖和编译时间。
# Pimpl 模式的实现步骤
- 头文件(接口):
- 只声明对外的接口。
- 使用一个指向实现类的指针来管理资源。
class Line { | |
public: | |
Line(int x1, int y1, int x2, int y2); | |
~Line(); | |
void printLine() const; // 打印 Line 对象的信息 | |
private: | |
class LineImpl; // 类的前向声明 | |
LineImpl * _pimpl; | |
}; |
- 实现文件(具体实现):
- 定义嵌套的实现类。
LineImpl
是Line
的内部类,Point
是LineImpl
的内部类 - 实现所有成员函数。
Line
类对外公布的接口都是使用LineImpl
进行具体实现的。
- 定义嵌套的实现类。
#include "Line.hpp" | |
#include <iostream> | |
class Line::LineImpl { | |
public: | |
class Point { | |
public: | |
Point(int x, int y) : _ix(x), _iy(y) {} | |
friend std::ostream& operator<<(std::ostream& os, const Point& p); | |
private: | |
int _ix; | |
int _iy; | |
}; | |
Point _pt1; | |
Point _pt2; | |
LineImpl(int x1, int y1, int x2, int y2) | |
: _pt1(x1, y1), _pt2(x2, y2) {} | |
}; | |
Line::Line(int x1, int y1, int x2, int y2) { | |
_pimpl = new LineImpl(x1, y1, x2, y2); | |
} | |
Line::~Line() { | |
delete _pimpl; | |
} | |
void Line::printLine() const { | |
std::cout << "Line from (" << _pimpl->_pt1._ix << ", " << _pimpl->_pt1._iy | |
<< ") to (" << _pimpl->_pt2._ix << ", " << _pimpl->_pt2._iy << ")" << std::endl; | |
} | |
std::ostream& operator<<(std::ostream& os, const Line::LineImpl::Point& p) { | |
os << "(" << p._ix << ", " << p._iy << ")"; | |
return os; | |
} |
- 测试文件:使用对外提供的接口。在测试文件中创建
Line
对象(最外层),使用Line
对外提供的接口,但是不知道具体的实现
#include "Line.hpp" | |
void test0(){ | |
Line line(10, 20, 30, 40); | |
line.printLine(); | |
} |
- 打包库文件:使用
ar
工具将对象文件打包成库文件。
sudo apt install build-essential | |
g++ -c LineImpl.cc -o LineImpl.o | |
ar rcs libLine.a LineImpl.o |
- 编译测试文件:使用库文件进行编译。
g++ Line.cc -L. -lLine -o LineTest |
# Pimpl 模式的好处
- 实现信息隐藏:类的实现细节被隐藏在私有的实现类中,头文件只需要包含必要的接口声明。
- 减少编译依赖:用户只需要包含头文件,不需要关心实现细节,减少了编译时的依赖。
- 编译时间优化:修改实现文件不需要重新编译头文件的包含者,只需要重新链接库文件。
- 库的平滑升级:只要接口不变,用户可以无缝替换库文件,实现库的平滑升级。
# 单例对象自动释放
在讨论类与对象的章节时,我们深入了解了单例模式的实现。在标准的单例模式中,一个名为 _pInstance
的静态指针用来保存单例对象的实例。通常,这个单例对象需要程序员手动调用一个 destroy
函数来释放。
然而,在实际的开发环境中,我们更倾向于单例对象能够自动释放,以减少内存泄漏的风险。为了检测内存泄漏,开发者通常会使用像 Valgrind 这样的工具。如果单例对象没有被正确释放,Valgrind 可能会将其误报为内存泄漏,这就需要程序员额外去验证和处理,增加了工作负担。
那么,如何确保单例对象能够自动释放呢?
当我们考虑自动释放时,自然就会想到析构函数。析构函数会在对象生命周期结束时自动被调用,我们可以利用这一点来实现单例对象的自动释放。
# 方式一:利用对象生命周期管理单例资源
在单例模式中,确保单例对象能够自动释放的一种方法是利用另一个对象的生命周期来管理单例资源。这可以通过在析构函数中释放单例对象来实现,从而确保当管理对象被销毁时,单例对象也会被自动释放。
- 析构函数自动调用:通过在管理对象的析构函数中释放单例对象,可以确保单例对象的生命周期与该管理对象的生命周期绑定。
- 避免手动释放:如果同时手动调用单例类的
destroy
函数,可能会导致重复释放(double free)问题。因此,可以去掉destroy
函数,完全依赖自动释放机制。 - 避免多个管理对象:不应让多个管理对象同时托管同一个单例对象,以防止释放逻辑的混乱。
示例代码:
class AutoRelease { | |
public: | |
AutoRelease(Singleton * p) : _p(p) { | |
std::cout << "AutoRelease(Singleton*)" << std::endl; | |
} | |
~AutoRelease() { | |
std::cout << "~AutoRelease()" << std::endl; | |
if (_p) { | |
delete _p; | |
_p = nullptr; | |
} | |
} | |
private: | |
Singleton * _p; | |
}; | |
void test0() { | |
AutoRelease ar(Singleton::getInstance()); | |
Singleton::getInstance()->print(); | |
} |
在这个例子中, AutoRelease
对象在构造时接收一个 Singleton
对象,并在析构时释放它。这确保了当 AutoRelease
对象的生命周期结束时, Singleton
对象也会被自动释放。
# 方式二:嵌套类与静态对象实现单例自动释放
在 C++ 中,通过使用嵌套类和静态对象,我们可以设计一个自动释放单例对象的机制。这种方法利用静态对象的生命周期特性,确保在程序结束时自动释放单例对象。
核心思想:
- 嵌套类:定义一个嵌套类
AutoRelease
,其作用是管理单例对象的生命周期。 - 静态对象:在程序的全局静态区域创建
AutoRelease
类的静态实例。这样,当程序结束时,AutoRelease
对象会被销毁,自动调用其析构函数。
实现步骤:
- 定义单例类:包含一个嵌套的
AutoRelease
类。 - 定义静态成员:在单例类中定义一个静态指针
_pInstance
指向单例对象,以及一个静态AutoRelease
对象_ar
。 - 实现自动释放:在
AutoRelease
的析构函数中释放_pInstance
。
示例代码:
class Singleton { | |
public: | |
static Singleton* getInstance() { | |
if (_pInstance == nullptr) { | |
_pInstance = new Singleton(); | |
} | |
return _pInstance; | |
} | |
~Singleton() { | |
cout << "Singleton destroyed" << endl; | |
} | |
void print(){ | |
cout << "(" << this->_ix | |
<< "," << this->_iy | |
<< ")" << endl; | |
} | |
void init(int x, int y) { | |
_ix = x; | |
_iy = y; | |
} | |
private: | |
int _ix; | |
int _iy; | |
static Singleton * _pInstance; | |
static class AutoRelease _ar; | |
class AutoRelease { | |
public: | |
~AutoRelease() { | |
if (_pInstance) { | |
delete _pInstance; | |
_pInstance = nullptr; | |
cout << "AutoRelease destroyed" << endl; | |
} | |
} | |
}; | |
}; | |
// 初始化静态成员 | |
Singleton* Singleton::_pInstance = nullptr; | |
Singleton::AutoRelease Singleton::_ar; |
程序结束时的自动销毁过程:
- 程序结束:程序运行结束,开始销毁全局静态对象。
- 销毁
AutoRelease
对象:全局静态区域的Singleton::_ar
对象被销毁。 - 调用析构函数:
Singleton::_ar
的析构函数被调用,释放Singleton::_pInstance
。 - 释放单例对象:
_pInstance
指向的单例对象被释放。
# 使用 atexit
和 destroy
实现单例自动释放
在 C++ 中,确保程序退出时执行必要的清理工作,如释放资源,可以通过注册 atexit
函数来实现。这种方法与程序的退出方式无关,无论是通过 main()
结束、调用 exit()
还是通过异常退出,注册的函数都会被调用。
实现步骤:
- 注册清理函数:在获取单例对象时,使用
atexit
注册一个清理函数destroy
。 - 实现清理函数:定义
destroy
函数来释放单例对象。
示例代码:
#include <atexit> | |
class Singleton { | |
public: | |
static Singleton* getInstance() { | |
if (_pInstance == nullptr) { | |
atexit(destroy); // 注册退出时调用的函数 | |
_pInstance = new Singleton(); | |
} | |
return _pInstance; | |
} | |
void init(int x,int y){ | |
_ix = x; | |
_iy = y; | |
} | |
void print(){ | |
cout << "(" << this->_ix | |
<< "," << this->_iy | |
<< ")" << endl; | |
} | |
private: | |
Singleton(int x = 0, int y = 0) : _ix(x), _iy(y) | |
{ | |
cout << "Singleton(int,int)" << endl; | |
} | |
~Singleton(){ | |
cout << "~Singleton()" << endl; | |
} | |
static void destroy() { | |
if(_pInstance){ | |
delete _pInstance; | |
_pInstance = nullptr; | |
cout << "delete heap" << endl; | |
} | |
} | |
static Singleton* _pInstance; | |
}; | |
Singleton* Singleton::_pInstance = nullptr; |
在多线程环境中,即使使用了 atexit
和 destroy
方法,仍然存在线程安全问题。如果多个线程同时访问 getInstance
函数,可能会创建多个单例对象,从而导致内存泄漏。
- 使用饿汉式初始化:在程序启动时立即创建单例对象,确保
getInstance
函数的第一次调用是在程序启动时。
示例代码:
class Singleton { | |
public: | |
Singleton() { | |
// 初始化代码 | |
} | |
void init(int x,int y){ | |
_ix = x; | |
_iy = y; | |
} | |
void print(){ | |
cout << "(" << this->_ix | |
<< "," << this->_iy | |
<< ")" << endl; | |
} | |
static Singleton* getInstance() { | |
return _pInstance; | |
} | |
private: | |
static Singleton* _pInstance; | |
// 饿汉式初始化 | |
static void initialize() { | |
_pInstance = new Singleton(); | |
atexit(destroy); | |
} | |
}; | |
Singleton* Singleton::_pInstance = nullptr; | |
// 在程序启动时调用 | |
Singleton::initialize(); |
- 将
destroy
函数私有化:防止在多线程环境中手动调用destroy
函数导致_pInstance
变为空指针。
# 方式四:结合 atexit
和 pthread_once
实现线程安全单例
在 Linux 平台上,为了确保在多线程环境中单例对象的创建是线程安全的,我们可以使用 pthread_once
函数。 pthread_once
保证初始化代码只执行一次,无论多少次调用 getInstance
方法。
pthread_once
是 POSIX 线程库(pthreads)中的一个函数,用于确保在多线程程序中某个初始化例程只执行一次。这在初始化全局变量或静态变量时非常有用,特别是当这些变量可能会被多个线程访问时。
函数原型:
#include <pthread.h> | |
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void)); |
参数:
once_control
:一个指向pthread_once_t
类型的变量的指针,这个变量用于记录初始化例程是否已经被执行过。必须由调用线程初始化为PTHREAD_ONCE_INIT
。init_routine
:一个函数指针,指向实际执行初始化工作的函数。这个函数不接受任何参数,并且没有返回值。
返回值:
- 返回
0
表示成功。 - 返回
EAGAIN
表示系统资源不足,无法完成请求。 - 返回
ENOMEM
表示内存分配失败。 - 返回
EINVAL
表示once_control
不是一个有效的初始化过的pthread_once_t
对象。
用法:
-
初始化:在程序中定义一个
pthread_once_t
类型的变量,并将其初始化为PTHREAD_ONCE_INIT
。pthread_once_t once_control = PTHREAD_ONCE_INIT;
-
定义初始化函数:定义一个执行初始化工作的函数。
void init_function() {
// 初始化代码
}
-
调用
pthread_once
:在需要进行初始化的地方调用pthread_once
函数。if (pthread_once(&once_control, init_function) != 0) {
// 处理错误
}
工作机制:
- 当第一个线程调用
pthread_once
并且once_control
变量为PTHREAD_ONCE_INIT
时,init_routine
函数会被调用。 - 在
init_routine
函数执行期间,once_control
变量会被修改为一个非PTHREAD_ONCE_INIT
的值,以防止其他线程再次执行init_routine
。 - 如果
init_routine
函数成功执行,once_control
变量会保持修改后的状态。这样,后续对pthread_once
的调用将直接返回,不会再次执行init_routine
。
注意事项:
pthread_once
只能保证init_routine
函数在多线程环境中只执行一次,它不提供锁的功能。pthread_once
不适用于局部静态变量的初始化,因为局部静态变量的初始化在 C 语言中已经是线程安全的。pthread_once
通常用于全局变量或静态变量的初始化。
关键点:
-
pthread_once
:确保初始化函数只执行一次。 -
atexit
:注册一个函数,在程序退出时自动调用。
实现步骤:
- 定义单例类:包含静态指针
_pInstance
和pthread_once_t
类型的静态变量_once
。 - 定义初始化函数:在初始化函数中创建单例对象,并注册
destroy
函数。 - 私有化初始化函数和销毁函数:防止手动调用造成问题。
示例代码:
#include <pthread.h> | |
#include <iostream> | |
class Singleton { | |
public: | |
// 仍然是通过 getInstance 函数使用单例对象,但不是简单 if 判断 | |
static Singleton* getInstance() { | |
// 创建对象的功能交给 init_r 函数 | |
//pthread_once 控制 init_r 只会执行一次 | |
pthread_once(&_once, init_r); | |
return _pInstance; | |
} | |
void init(int x, int y) { | |
_ix = x; | |
_iy = y; | |
} | |
void print() { | |
std::cout << "(" << this->_ix << "," << this->_iy << ")" << std::endl; | |
} | |
private: | |
//init_t 不能被手动调用,否则会造成内存泄露 | |
// 绕开了 getInstance 中通过 pthread_once 进行的控制 | |
static void init_r() { | |
_pInstance = new Singleton(); | |
atexit(destroy); | |
} | |
static void destroy() { | |
if (_pInstance) { | |
delete _pInstance; | |
_pInstance = nullptr; | |
} | |
} | |
Singleton() = default; // C++11 default constructor | |
~Singleton() { | |
std::cout << "~Singleton()" << std::endl; | |
} | |
Singleton(const Singleton& rhs) = delete; | |
Singleton& operator=(const Singleton& rhs) = delete; | |
private: | |
int _ix; | |
int _iy; | |
static Singleton* _pInstance; | |
static pthread_once_t _once; | |
}; | |
Singleton* Singleton::_pInstance = nullptr; | |
pthread_once_t Singleton::_once = PTHREAD_ONCE_INIT; |
注意事项:
- 私有化初始化函数:防止手动调用
init_r
函数绕过pthread_once
的控制,导致内存泄漏。 - 私有化销毁函数:防止手动调用
destroy
函数导致再次调用getInstance
时无法创建单例对象。
# std::string
的底层实现
当我们探讨 std::string
的内存占用时,我们实际上是在考察其底层实现机制。 std::string
对象的大小可以通过 sizeof(std::string)
来测量,但这个值会因不同编译器和不同版本的编译器而异。
历史上, std::string
的实现有多种方式,主要包括:
-
Eager Copy(立即复制):在这种实现中,无论何时复制字符串,都会完整地复制其内容。这种方式简单,但在频繁复制不修改的字符串时效率不高。
-
Copy-On-Write (COW,写时复制):这种技术延迟了复制操作,直到字符串的内容需要被修改时才进行。这减少了不必要的数据复制,提高了效率。
-
Short String Optimization (SSO,短字符串优化):这是目前大多数标准库实现
std::string
时采用的技术。它允许短字符串直接存储在std::string
对象内部,从而避免了频繁的内存分配。
std::string
的底层实现是面试中的一个常见问题。虽然现代的实现通常采用 SSO,但了解其发展过程中的不同设计思想也是非常有价值的。
以立即复制为例,无论什么情况下复制字符串,都会拷贝其内容,这种方式在我们之前已经讨论过。但这种方式在处理不需要修改内容的字符串时效率不高,因此后来发展出了写时复制的实现方式。
// 如果 std::string 的实现采用立即复制 | |
std::string str1("hello, world"); | |
std::string str2 = str1; // 立即复制,str2 会重新申请空间并复制内容 |
在立即复制的实现中,创建 str2
时会重新申请内存并复制 str1
的内容,这在内容不需要改变时效率较低。
现代 std::string
实现通常采用 SSO,这样在创建短字符串时可以减少内存分配的开销。但 sizeof(std::string)
的大小仍然取决于编译器的实现细节。
# 写时复制原理探究
写时复制是一种在对象复制时不立即复制数据,而是让多个对象共享相同的数据,直到数据需要修改时才进行复制的优化技术。
Q1: 何时回收堆空间的字符串内容?
在写时复制模式中,多个 std::string
对象可能指向同一块堆内存中的字符串数据。引用计数(refcount)是管理这块内存的关键机制:
- 复制操作:当一个
std::string
对象复制给另一个时,两者的引用计数都指向相同的字符串数据,引用计数加 1。 - 销毁对象:当一个
std::string
对象被销毁时,引用计数减 1。 - 回收内存:只有当引用计数减至 0 时,即没有对象使用该字符串数据时,堆空间上的字符串数据才会被释放。
示例:
std::string str2("hello,wuhan"); | |
std::string str3 = str2; |
在这种情况下, str2
和 str3
共享相同的字符串数据。直到其中一个对象尝试修改字符串时,才会创建新内存空间。
Q2:引用计数的位置
-
普通数据成员:将引用计数作为普通成员变量会导致一个对象的改变影响其他对象,这是不合理的。
-
静态成员:静态成员变量被类的所有对象共享,无法精确反映每个对象的引用情况。
-
堆空间:将引用计数存储在堆空间中是可行的,并且可以进一步优化。
-
优化方案:将引用计数和字符串内容保存在一起,通常引用计数保存在字符串内容的前面,这样可以方便地访问和更新计数。
-
合并分配:将引用计数和字符串内容合并到一个分配块中,这样只需要一次 new
表达式即可分配两者所需的内存。
复制和赋值操作
- 复制操作:复制构造函数通过增加引用计数来共享相同的字符串数据。
- 赋值操作:赋值操作需要先减少原对象字符串的引用计数(如果计数减至 0,则释放内存),然后将对象的指针指向新字符串的内存,并增加新内存的引用计数。
- 原本空间的引用计数 - 1,引用计数减到 0,才真正回收堆空间
- 让自己的指针指向新的空间,并将新空间的引用计数 +1
# 写时复制的实现
实现一个支持写时复制的字符串类是一个深入理解 COW 机制的好方法。我们将重点关注赋值运算符和下标访问运算符的实现,因为它们是 COW 实现中最关键的部分。
# 赋值运算符的实现
赋值运算符需要特别考虑何时复制共享的数据。如果两个字符串对象共享相同的数据,赋值操作可能导致需要独立拥有一份数据副本。
实现细节:
- 当字符串对象进行复制操作时,引用计数增加。
- 当字符串对象被销毁时,引用计数减少。
- 只有当引用计数减为 0 时,才真正回收堆空间上的字符串。
CowString & CowString::operator=(const CowString &rhs) { | |
if(this != &rhs){ // 自赋值的判断 | |
release(); // 尝试回收空间 | |
_pstr = rhs._pstr; // 修改指向 | |
increaseRefcount(); // 增加新空间的引用计数 | |
} | |
return *this; // 返回本对象 | |
} |
# 下标访问运算符的实现
下标访问运算符不能简单地返回对应位置的字符,因为它需要区分读操作和写操作。
实现细节:
- 对于读操作,直接返回字符。
- 对于写操作,需要创建一个新的数据副本以进行修改。
class CharProxy { | |
public: | |
CharProxy(CowString& self, size_t idx) : _str(self), _index(idx) {} | |
// 重载输出流运算符 | |
friend ostream& operator<<(ostream& os, const CharProxy& rhs) { | |
os << rhs._pstr; | |
return os; | |
} | |
// 重载赋值运算符 | |
CharProxy& operator=(char ch) { | |
_self.writeChar(_index, ch); | |
return *this; | |
} | |
// 类型转换函数,用于读操作 | |
operator char() const { | |
return _str._pstr[_idx]; | |
} | |
private: | |
CowString & _self; | |
size_t _idx; | |
}; | |
CharProxy operator[](size_t idx) { | |
return CharProxy(*this, idx); | |
} |
# 遗留问题
如果 str1
和 str3
共享同一块内存空间存储字符串内容,我们需要确保写操作不会影响读操作。
问题描述:
- 读操作(如
cout << str1[0] << endl;
)可以直接进行,无需复制或更改引用计数。 - 写操作(如
str1[0] = 'H';
)需要让str1
申请新的空间进行修改,以保证不影响str3
的内容。
解决方案:
- 创建一个内部类
CharProxy
,让CowString
的operator[]
函数返回这个新类型的对象。 - 在
CharProxy
类中重载<<
和=
运算符,以便区分读操作和写操作。
)
// 读操作 | |
std::cout << str1[0] << std::endl; // 直接读取字符 | |
// 写操作 | |
str1[0] = 'H'; // 触发写时复制,创建新空间进行修改 |
class CowString { | |
class CharProxy { | |
public: | |
CharProxy(CowString & self,size_t idx) | |
: _self(self) | |
, _idx(idx) | |
{} | |
char operator=(char ch); | |
// 初步的模拟,完善的代码还需要加上判断 | |
CharProxy & operator=(const CharProxy & rhs){ | |
_self._pstr[_idx] = rhs._self._pstr[rhs._idx]; | |
return *this; | |
} | |
operator char(){ | |
return _self._pstr[_idx]; | |
} | |
friend ostream & operator<<(ostream & os,const CharProxy & ); | |
private: | |
CowString & _self; | |
size_t _idx; | |
}; |
//str1 [0] 返回一个 CharProxy 对象 | |
// 能够利用的材料只有 str1 和 下标 | |
CowString::CharProxy CowString::operator[](size_t idx){ | |
return CharProxy(*this,idx); | |
} | |
char CowString::CharProxy::operator=(char ch) { | |
if(_idx < _self.size()){ | |
if(_self.use_count() > 1){ | |
_self.decreaseRefcount(); // 原本空间的引用计数 -1 | |
char * temp = _self.malloc(_self._pstr); // 深拷贝 | |
strcpy(temp,_self._pstr); | |
_self._pstr = temp; // 改变指向 | |
_self.initRefcount(); // 初始化新空间的引用计数 | |
} | |
_self._pstr[_idx] = ch; // 完成赋值操作 | |
return _self._pstr[_idx]; | |
}else{ | |
static char nullchar = '\0'; | |
return nullchar; | |
} | |
} |
总结:当运算符需要处理自定义类型对象时,先看一看这个自定义类型有没有相应的运算符重载函数,如果有,那么这个运算符就可以处理这个自定义类型对象;
如果没有运算符重载,就无法直接处理,需要进行转换。先看看这个自定义类型中有没有类型转换函数,转换成一个该运算符可以直接处理的类型的数据。如果没有类型转换函数,会再看看有没有隐式转换的途径。(一般,大多数情况谨慎使用隐式转换)
# 短字符串优化(SSO)
短字符串优化(Short String Optimization, SSO)是现代 C++ 标准库实现 std::string
类时采用的一种技术。这种技术可以减少小字符串的内存开销和提高其性能。
核心思想:
- 小字符串:当字符串的长度小于或等于一个特定值(通常为 15 或更多,取决于实现)时,字符串数据直接存储在
std::string
对象内部的字符数组中。 - 大字符串:当字符串长度超过这个特定值时,内部的指针指向堆上分配的内存区域,用于存储字符串数据。
实现的好处:
- 减少内存开销:对于短字符串,避免了额外的堆内存分配,因为数据直接存储在
std::string
对象内部。 - 提高效率:拷贝短字符串时,只需拷贝对象内部的字符数组,而不需要进行堆内存的分配和释放。
# fbstring
Facebook 提出的策略是一种折衷方案,结合了 Eager Copy、Copy-On-Write (COW) 和 Short String Optimization (SSO) 这三种技术的优点,以适应不同长度的字符串。这种策略在 Facebook 的开源库 folly 中实现为 fbstring 类。
-
短字符串优化(SSO):
- 对于非常短的字符串(长度 0 到 22 个字符),使用 SSO。这是因为短字符串在实际应用中非常常见,使用 SSO 可以避免频繁的内存分配和释放,从而提高性能。
- 在这个长度范围内,字符串直接存储在 fbstring 对象内部的字符数组中,无需动态分配内存。
-
立即复制(Eager Copy):
- 对于中等长度的字符串(23 到 255 个字符),使用 Eager Copy 策略。这是因为中等长度的字符串复制成本相对较低,且预测复制次数较少,因此直接复制是可行的。
- 这种策略下,fbstring 对象包含一个指向堆上动态分配的字符串数据的指针,以及表示字符串大小和容量的字段。
-
写时复制(COW):
- 对于长字符串(超过 255 个字符),使用 COW 策略。这是因为长字符串的复制成本很高,使用 COW 可以避免不必要的数据复制,直到字符串内容实际被修改时才进行复制。
- 在这种策略下,fbstring 对象包含一个指向堆上的字符串数据和引用计数的指针,这样可以确保多个 fbstring 对象可以共享相同的字符串数据,直到其中一个对象修改字符串时才进行复制。
内存布局:
每个 fbstring 对象占用的内存大小固定为 24 字节,具体布局如下:
-
短字符串(SSO):
- 23 字节用于存储字符串数据(包括空字符 '\0')。
- 1 字节用于存储字符串的长度。
-
中等长度字符串(Eager Copy):
- 8 字节用于存储指向字符串数据的指针。
- 8 字节用于存储字符串的长度。
- 8 字节用于存储字符串的容量。
-
长字符串(COW):
- 8 字节用于存储指向字符串数据和引用计数的指针。
- 8 字节用于存储字符串的长度。
- 8 字节用于存储字符串的容量。