特殊运算符
在 C++ 中,有一些特殊运算符,如 ++
、--
、new
、delete
等,它们与传统的一元或二元运算符(例如 +
、<
、~
)有所不同。这些特殊运算符在使用时,从代码中的使用到程序员定义的映射与传统运算符有轻微的差别。其中,取下标([]
)和函数调用(()
)是两种最重要的用户自定义运算符。
取下标运算符([]
)
通过 operator[]
函数,我们可以为类对象的下标赋予新的含义。operator[]
函数的第二个参数(下标)可以是任意类型,因此常被用于定义类似 vector
、关联数组等类型。
operator[]()
必须是非 static 成员函数。
#include <iostream>
#include <map>
#include <string>
class StringIntMap {
private:
std::map<std::string, int> data;
public:
// 重载下标运算符
int& operator[](const std::string& key) {
return data[key];
}
// 为了完整性,我们也提供一个const版本的operator[]
const int& operator[](const std::string& key) const {
return data.at(key);
}
};
int main() {
StringIntMap map;
// 使用下标运算符设置值
map["apple"] = 1;
map["banana"] = 2;
map["cherry"] = 3;
// 使用下标运算符获取值
std::cout << "apple: " << map["apple"] << std::endl;
std::cout << "banana: " << map["banana"] << std::endl;
std::cout << "cherry: " << map["cherry"] << std::endl;
return 0;
}
函数调用运算符(()
)
在 C++ 中,函数调用运算符 ()
可以像其他运算符一样被重载,使得对象能够像函数一样被调用。这种运算符重载对于创建类似函数的对象(函数对象)特别有用,它们可以保存数据并在调用时执行操作。
以下是一个名为 Add
的类,它是一个函数对象,用于将一个复数加到另一个复数上:
class Add {
complex val;
public:
Add(complex c) : val(c) {}
Add(double r, double i) : val(r, i) {}
void operator()(complex& c) const {
c += val;
}
};
使用 Add
类的示例:
void h(vector<complex>& vec, list<complex>& lst, complex z) {
for_each(vec.begin(), vec.end(), Add{2, 3});
for_each(lst.begin(), lst.end(), Add{z});
}
在这个例子中,Add{2, 3}
创建了一个对象,它将复数 {2, 3}
加到 vector
的每个元素上。Add{z}
创建了另一个对象,它将变量 z
加到 list
的每个元素上。
Lambda 表达式本质上是定义函数对象的一种方式。
operator()
必须是非 static
成员函数。
解引用运算符(->
)
解引用运算符 ->
,也称为箭头运算符,可以被定义为一个一元后置运算符。重载 ->
的主要目的是创建“智能指针”,即行为类似于指针的对象,并且在访问对象时执行某些操作。标准库中的“智能指针”如 unique_ptr
和 shared_ptr
提供了 ->
运算符。
#include <iostream>
class MyClass {
public:
void Display() {
std::cout << "Hello from MyClass!" << std::endl;
}
};
class SmartPointer {
private:
MyClass* ptr;
public:
SmartPointer(MyClass* p = nullptr) : ptr(p) {}
~SmartPointer() {
delete ptr;
}
// 重载箭头运算符,使其可以像普通指针一样访问 MyClass 的成员
MyClass* operator->() {
return ptr;
}
};
int main() {
SmartPointer sp(new MyClass());
sp->Display(); // 使用重载的箭头运算符访问 MyClass 的成员
sp.operator->()->Display(); // 等价于上一行代码
return 0;
}
返回值最好是指针,或者支持再次使用 ->
的类对象,因为在调用 ->
运算符后,还会再次使用 ->
。
运算符 ->
必须是非 static
成员函数。
递增和递减
在 C++ 中,递增运算符 ++
和递减运算符 --
可以用作前置运算符(如 ++i
和 --i
)以及后置运算符(如 i++
和 i--
)。
#include <iostream>
using namespace std;
class Counter {
private:
int value;
public:
// 构造函数
Counter(int v = 0) : value(v) {}
// 前置递增运算符 ++
Counter& operator++() {
++value;
return *this;
}
// 后置递增运算符 ++ (注意参数 int 用来区分后置)
Counter operator++(int) {
Counter temp = *this; // 保存当前值
value++; // 增加当前对象的值
return temp; // 返回保存的旧值
}
// 前置递减运算符 --
Counter& operator--() {
--value;
return *this;
}
// 后置递减运算符 -- (注意参数 int 用来区分后置)
Counter operator--(int) {
Counter temp = *this; // 保存当前值
value--; // 减少当前对象的值
return temp; // 返回保存的旧值
}
// 打印当前计数值
void print() const {
cout << "Counter value: " << value << endl;
}
};
这里的 int
参数用于区分前置和后置版本,后置版本中的 int
参数是一个哑参数,用于表示这是一个后置运算符。
在设计中,可以考虑去掉后置的 ++
和 --
运算符,因为它们在语法上有些奇怪,实现起来较难,效率较低,且应用范围较小。
分配和释放内存
new
和 delete
运算符分别用于分配和释放内存。用户可以重新定义全局的 operator new()
和 operator delete()
,也可以为特定类定义这些运算符。
全局版本的 operator new()
和 operator delete()
void* operator new(size_t); // 用于单个对象
void* operator new[](size_t); // 用于数组
void operator delete(void*, size_t); // 用于单个对象
void operator delete[](void*, size_t); // 用于数组
当 new
需要为类型 X
的对象分配内存时,它调用 operator new(sizeof(X))
。类似地,当 new
需要为包含 N
个 X
对象的数组分配内存时,它调用 operator new[](N*sizeof(X))
。new
表达式实际分配的空间可能比 N*sizeof(X)
多一些,超出的部分可以容纳若干字符。
特定类的 operator new()
和 operator delete()
更好的做法是为特定的类提供这些操作,其中,这个特定的类可以作为很多派生类的基类。例如,为 Employee
类及其派生类提供特定的分配和释放函数:
#include <iostream>
#include <cstdlib>
class Base {
public:
// 重载 operator new
void* operator new(std::size_t size) {
std::cout << "Base::operator new called, size = " << size << std::endl;
void* p = std::malloc(size); // 分配内存
if (!p) throw std::bad_alloc(); // 如果分配失败,抛出异常
return p;
}
// 重载 operator delete
void operator delete(void* p, std::size_t size) {
std::cout << "Base::operator delete called, size = " << size << std::endl;
std::free(p); // 释放内存
}
virtual ~Base() = default; // 虚析构函数以确保正确删除派生类对象
};
class Derived : public Base {
public:
int data;
Derived(int value) : data(value) {
std::cout << "Derived constructor, data = " << data << std::endl;
}
};
int main() {
Base* b = new Derived(10); // 使用 Base::operator new 进行分配
delete b; // 使用 Base::operator delete 释放
return 0;
}
成员函数 operator new()
和 operator delete()
是隐式的 static
成员,因此它们无法使用 this
指针,也不能修改对象的值。它们提供了一块可供构造函数初始化并由析构函数释放的存储空间。
std::size_t size
参数等于实际类的大小,比如上面的输出是:
Base::operator new called, size = 16
Derived constructor, data = 10
Base::operator delete called, size = 16
用户自定义字面值常量
用户自定义字面值常量(user-defined literal
)是通过字面值常量运算符(literal operator
)定义的,这类运算符负责把带后缀的字面值常量映射到目标类型。字面值常量运算符的名字由 operator""
加上后缀组成,例如:operator"" _cm
。
#include <iostream>
// 定义一个字面值常量运算符
long double operator"" _cm(long double x) {
return x * 10;
}
int main() {
long double height = 3.4_cm;
std::cout << "Height in mm: " << height << std::endl;
return 0;
}
使用 constexpr
可以确保在编译时求值。
可添加后缀的字面值常量类型:
- 整型字面值常量:可以用于接受
unsigned long long
或者const char*
参数的字面值常量运算符,也可用于模板字面值常量运算符。例如,123m
和12345678901234567890X
。 - 浮点型字面值常量:可以用于接受
long double
或者const char*
参数的字面值常量运算符,也可用于模板字面值常量运算符。例如,12345678901234567890.976543210x
和3.99s
。 - 字符串字面值常量:可以用于接受
(const char*, size_t)
参数对的字面值常量运算符。例如,"string"s
和R"(Foolbar)"_path
。 - 字符字面值常量:可以用于接受
char
、wchar_t
、char16_t
、char32_t
等字符类型参数的字面值常量运算符。例如,'f'_runic
和u'BEEF"_W
。
以下是一个定义字面值常量运算符的例子,用于存放任意内置整数类型都无法表示的整型值:
Bignum operator""x(const char* p) {
return Bignum(p);
}
void f(Bignum);
f(123456789012345678901234567890123456789012345x);
在这个例子中,operator""x
是一个用户自定义的字面值常量运算符,它接受一个 const char*
参数,并返回一个 Bignum
对象。然后,这个运算符被用来创建一个非常大的整数字面值。注意,在代码中,并未在数字串两端使用双引号,编译器负责把数字转换成所需的类型。
不以下划线开始的后缀都是为标准库预留的,因此程序员最好把自己的后缀设计为以下划线开始,以避免未来因标准库的扩充而失效。
友元
友元函数和友元类是 C++ 中的特殊机制,它们允许非成员函数或类访问另一个类的私有成员。这种机制在需要外部函数或类访问类的内部数据时非常有用,尤其是在类的封装性和数据隐藏需要被严格控制的情况下。
友元函数
假设我们有两个类 Matrix
和 Vector
,我们希望定义一个计算矩阵与向量乘积的运算符 operator*
。由于乘法运算不应属于 Matrix
或 Vector
中的任何一个,我们可以将 operator*
声明为这两个类的友元函数。
constexpr int rc_max = 4; // 行列的尺寸
class Matrix;
class Vector {
float v[rc_max];
public:
friend Vector operator*(const Matrix&, const Vector&);
};
class Matrix {
Vector v[rc_max];
public:
friend Vector operator*(const Matrix&, const Vector&);
};
Vector operator*(const Matrix& m, const Vector& v) {
Vector r;
for (int i = 0; i != rc_max; i++) {
r.v[i] = 0;
for (int j = 0; j != rc_max; j++)
r.v[i] += m.v[i].v[j] * v.v[j];
}
return r;
}
在这个例子中,operator*
可以访问 Vector
和 Matrix
的私有成员,但用户不能直接访问这些类的内部表示。
友元类
友元类的所有成员函数都可以访问另一个类的私有成员。这种声明方式允许两个类之间有更紧密的联系,但同时也意味着对类的内部细节的访问权限更加开放。
假设我们有一个 List
类和一个 List_iterator
类,我们希望 List_iterator
类的所有成员函数都能访问 List
类的私有成员。
class List_iterator {
// ...
};
class List {
friend class List_iterator;
// ...
};
在这个例子中,List_iterator
类的所有成员函数都被声明为 List
类的友元,因此它们可以访问 List
类的私有成员。
此外,模板参数也可以设置为友元。
template<typename T>
class X {
friend T;
friend class T; // 多余的 "class"
};
友元发现
在 C++ 中,友元声明必须在类的外层作用域中提前声明,或者定义在直接外层非类作用域中。友元的声明对于嵌套名字空间作用域内的首次声明是敏感的,其友元性在更外层的作用域中可能会失效。
- 友元类:必须在类的外层作用域中提前声明。
- 友元函数:必须在类的外层作用域中提前声明,或者定义在直接外层非类作用域中。
// 全局作用域
class C1; // 将成为N::C的友元
void f1(); // 将成为N::C的友元
namespace N {
class C2; // 将成为C的友元
void f2() {} // 将成为C的友元
class C {
int x;
public:
friend class C1; // OK(已经预先定义)
friend void f1(); // OK
friend class C3; // OK(已经在外层作用域中定义)
friend void f3(); // 需要在N作用域中声明
friend class C4; // 需要在N作用域中声明
friend void f4(); // 需要在N作用域中声明
};
}
class C4 {}; // 不是N::C的友元
void f4() { N::C x; x.x = 1; } // 错误:x是私有的并且f4()不是N::C的友元
即使友元函数不是在直接外层作用域中声明,也可以通过其参数找到它。友元函数应该显式地声明在外层作用域中,或者接受一个数据类型为该类或者其派生类的参数;否则,我们无法调用该友元函数。
class X {
friend void f(); // 没用,因为f没有在X的外层作用域中声明
friend void h(const X&); // 可以通过参数找到
};
void g(const X& x) {
f(); // 作用域内找不到f()
h(x); // X的友元h()
}
在这个例子中,f
没有在 X
的外层作用域中声明,因此即使 f
被声明为 X
的友元,也无法在 g
函数中调用 f
。而 h
函数通过参数 const X&
找到了 X
的友元,因此可以在 g
函数中调用 h
。
友元与成员函数
何时使用成员函数
- 需要修改对象状态的操作:成员函数可以直接修改对象的状态,因为它们可以直接访问对象的成员变量。
- 构造函数、析构函数和虚函数:这些函数通常必须作为成员函数出现。
- 直接访问类的表示:如果函数需要直接访问类的内部数据,它们应该被定义为成员函数。
何时使用友元函数
- 不需要直接访问类的表示:如果函数不需要修改对象的状态,或者不需要直接访问类的私有成员,它们可以被定义为友元函数。
- 二元运算符:如果运算符的所有操作数都能进行隐式类型转换,那么这些运算符函数通常被定义为接受
const
引用参数或非引用参数的非成员函数。(因为非const
引用参数只接受左值,不接受类型转换)