【Effective C++】阅读笔记6
1. 需要类型转换时将模板定义为非成员函数
使用非成员函数的原因
首先分析成员函数重载行为
当类中定义一个运算符重载为成员函数的时候,这个运算符的第一个操作数(左操作数)必须是该类对象;隐式类型转换只可以在右操作数上进行,而不可以在做操作数上运行
例如下述代码中的a+3,a的类型正确但是3类型不正确,虽然可以通过构造函数转换为 MyClass<int>,但是成员函数重载的规则是不允许对右操作数进行隐式转换的,所以这样就会导致编译失败。
template <typename T>
class MyClass {
public:
MyClass(T value) : value_(value) {}
// 成员函数重载 +
MyClass operator+(const MyClass& other) const {
return MyClass(value_ + other.value_);
}
private:
T value_;
};
int main() {
MyClass<int> a(5);
MyClass<int> b(10);
auto c = a + b;
auto d = a + 3; // 编译错误:右侧的 int 不能隐式转换为 MyClass<int>
return 0;
}
然后分析非成员函数重载行为
将运算符重载定义为非成员函数,那么隐式类型转换就可以在两个操作数上进行
template <typename T>
class MyClass {
public:
MyClass(T value) : value_(value) {}
T getValue() const { return value_; }
private:
T value_;
};
// 非成员函数重载 +
template <typename T>
MyClass<T> operator+(const MyClass<T>& lhs, const MyClass<T>& rhs) {
return MyClass<T>(lhs.getValue() + rhs.getValue());
}
// 非成员函数重载 +,支持右侧为基本类型
template <typename T>
MyClass<T> operator+(const MyClass<T>& lhs, const T& rhs) {
return MyClass<T>(lhs.getValue() + rhs);
}
// 非成员函数重载 +,支持左侧为基本类型
template <typename T>
MyClass<T> operator+(const T& lhs, const MyClass<T>& rhs) {
return MyClass<T>(lhs + rhs.getValue());
}
int main() {
MyClass<int> a(5);
auto b = a + 3; // 正常工作:int 被隐式转换为 MyClass<int>
std::cout << "b=" << b.getValue() << std::endl;
auto c = 3 + a; // 正常工作:int 被隐式转换为 MyClass<int>
std::cout << "c=" << c.getValue() << std::endl;
return 0;
}
成员函数重载会限制隐式类型转换的原因
C++中使用成员函数进行运算符重载的时候。左操作数必须是类类型的对象,也就是说成员函数的隐式this指针决定了左操作数的类型;编译器只能对右操作数进行类型转换,无法对做操作数进行类型转换,因为成员函数绑定的是左操作数的类实例。
但是非成员函数没有绑定this指针,所以编译器可以在两个操作数上都进行类型转换
总结与反思
- 运算符重载应该优先使用非成员函数,尤其是涉及到类型转换的时候,将运算符重载定义为非成员函数会更加灵活
- 非成员函数更加时候二元运算符(例如+\-\*\/等)
- 虽然非成员函数会让类型转换更加灵活,但是设计执行的时候应该注意其潜在的错误
2. 区分成员函数重载和模板化的成员函数
成员函数重载
同一个类中定义多个同名函数,其参数类型或者数量可能是不同的,是一种静态多态(即编译时选择要调用的函数)的实现方式
#include <iostream>
class Printer {
public:
void print(int value) {
std::cout << "打印整数:" << value << std::endl;
}
void print(double value) {
std::cout << "打印浮点数:" << value << std::endl;
}
void print(const std::string& value) {
std::cout << "打印字符串:" << value << std::endl;
}
};
int main() {
Printer printer;
printer.print(42); // 调用 print(int)
printer.print(3.14); // 调用 print(double)
printer.print("2024-11-10"); // 调用 print(const std::string&)
return 0;
}
模板化的成员函数
也就是定义一个通用的函数,该函数可以接受不同类型的参数,不需要为每一个类型都专门定义一个重载版本,是一种编译期多态的实现方式
#include <iostream>
#include <string>
class Printer {
public:
// 成员函数模板
template <typename T>
void print(const T& value) {
std::cout << "打印值:" << value << std::endl;
}
};
int main() {
Printer printer;
printer.print(42); // 调用模板化的 print<int>
printer.print(3.14); // 调用模板化的 print<double>
printer.print("2024-11-10"); // 调用模板化的 print<const char*>
return 0;
}
两者优点分析
成员函数重载
- 允许我们根据不同的类型提供特定实现,如果不同类型的参数需要不同的处理逻辑,那么选择重载是比较好的选择
- 每个重载版本都有特定行为, 可以让代码逻辑更加清晰可读
模板成员函数
- 通用性强,适合处理逻辑相同但是类型不同的情况
- 相比于成员函数重载,可以减少代码重复
两者结合使用
此时要注意二义性问题的出现
#include <iostream>
#include <string>
class Printer {
public:
// 针对 int 类型的重载
void print(int value) {
std::cout << "打印整数:" << value << std::endl;
}
// 针对 double 类型的重载
void print(double value) {
std::cout << "打印浮点数:" << value << std::endl;
}
// 通用的模板化成员函数
template <typename T>
void print(const T& value) {
std::cout << "打印通用类型:" << value << std::endl;
}
};
int main() {
Printer printer;
printer.print(42); // 调用重载 print(int)
printer.print(3.14); // 调用重载 print(double)
printer.print("10-11-10"); // 调用模板化的 print<const char*>
return 0;
}
总结反思
- 当不同类型需要不同的行为时,选择成员函数重载;当逻辑相同但是类型不同的时候,使用模板化成员函数
- 同时使用重载和模板化成员函数的时候,要确保没有重叠的情况,否则就会出现二义性错误,编译器无法确定调用哪个函数
3. 认识模板元编程
什么是模板元编程
通过代码使得计算在编译期完成,而不是在运行的时候进行。也就是说某些逻辑可以在编译阶段就确定下来,从而减少运行时候的开销
模板元编程主要有以下特点
- 编译时计算,利用模板递归和模板特化在编译的时候进行复杂的逻辑逻辑和运算
- 编译期进行类型推导、类型转换和类型判断
- 避免运行时候的计算,从而提高效率
计算阶乘时元编程的使用
Factorial<N>是一个递归模板,其在编译期计算N多阶乘,通过模板Factorial<0>定义了阶乘的递归终止条件,这样就实现了在编译期间完成所有计算,所以程序运行的时候几乎是没有开销的(调试的时候也是可以看到不会跳转到模板进行处理的,而是预先计算好直接出结果)
#include <iostream>
// 定义一个模板类,用于计算阶乘
template <int N>
struct Factorial {
static const int value = N * Factorial<N - 1>::value;
};
// 特化模板,处理 N = 0 的情况
template <>
struct Factorial<0> {
static const int value = 1;
};
int main() {
std::cout << "5 的阶乘是:" << Factorial<5>::value << std::endl;
std::cout << "0 的阶乘是:" << Factorial<0>::value << std::endl;
return 0;
}
应用场景1:编译期类型检查
编译期检查类型是否满足特定条件,从而避免类型错误
#include <iostream>
#include <type_traits>
template <typename T>
void printType() {
if constexpr (std::is_integral<T>::value) {
std::cout << "整数类型" << std::endl;
}
else {
std::cout << "非整数类型" << std::endl;
}
}
int main() {
printType<int>();
printType<double>();
return 0;
}
补充if constexpr-编译期条件判断
C++17引入的用于在编译期间执行条件判断,如果条件为假,编译器就会在编译期移除对应的代码分支,从而避免编译错误
#include <iostream>
#include <type_traits>
template <typename T>
void printValue(const T& value) {
if constexpr (std::is_integral<T>::value) {
std::cout << "这是一个整数:" << value << std::endl;
}
else if constexpr (std::is_floating_point<T>::value) {
std::cout << "这是一个浮点数:" << value << std::endl;
}
else {
std::cout << "其他类型" << std::endl;
}
}
int main() {
printValue(42);
printValue(3.14);
printValue("中国");
return 0;
}
应用场景2:静态断言
元编程可以用于编译期的断言,从而确保某些条件在编译期就得到满足,而不是在运行的时候抛错误
template <typename T>
void ensureInteger() {
static_assert(std::is_integral<T>::value, "模板参数必须是整数类型");
}
int main() {
ensureInteger<int>(); // 编译通过
ensureInteger<double>();
return 0;
}
补充static_assert:静态断言
- std::is_intergral<T>::value:用于判断T是否为整数类型,如果不是整数类型就会抛出后面的字符串
- 主要作用就是在编译期确保某些条件成立,从而防止类型错误进入运行时
应用场景3:条件编译
通过模板元编程可以在编译期选择不同的实现路径,从而避免不必要的运行开销
#include <iostream>
template <bool Condition>
struct CompileTimeSwitch;
template <>
struct CompileTimeSwitch<true> {
static void execute() {
std::cout << "条件为真" << std::endl;
}
};
template <>
struct CompileTimeSwitch<false> {
static void execute() {
std::cout << "条件为假" << std::endl;
}
};
int main() {
CompileTimeSwitch<true>::execute();
CompileTimeSwitch<false>::execute();
return 0;
}
优劣分析
优点
- 编译期就可以完成运算,减少了运行的开销
- 编译期间进行类型检查,避免了潜在的运行错误
缺点
- 编译时间加长
- 语法复杂,不好理解和维护,同时也不好调试
总结反思
- 在一些性能要求高的场景中,通过模板元编程可以提高性能,减少不必要的开销
- 避免过度使用,因为其代码复杂,编写和维护都难
4. 合理使用new 和 delete
正确使用方法
new在堆上分配动态内存,delete则用来释放new分配的内存,以避免内存泄漏,最后还有及时将new返回的指针置空,防止其成为悬空指针
#include <iostream>
int main() {
int* p = new int(42); // 使用 new 分配内存
std::cout << "值:" << *p << std::endl;
delete p; // 释放内存
p = nullptr; // 避免悬空指针
return 0;
}
内存泄漏和悬空指针
动态分配的内存没有及时释放就会导致内存泄漏,内存泄漏就会不断消耗系统内存,最终导致程序崩溃
悬空指针则是内存释放后,指向该内存的指针仍然保留原有地址。对悬空指针的访问会导致未定义的行为
建议使用智能指针对内存空间进行管理
总结反思
- 针对于动态内存分配的场景,优先使用智能指针,避免使用new和delete
- 如果使用了new和delete则要注意悬空指针和内存泄漏问题
5. 理解new-handler的行为
理解new-handler
new-handler是一种函数指针,当new操作符无法分配内存的时候,就会调用new-handler函数。可以通过设置new-handler来控制内存不足的行为而不是让程序崩溃
一般在内存使用紧张的场景下,确保程序不会因为new失败而直接崩溃。还可以通过new-handler提供内存清理、日志记录或者重试逻辑,以尝试释放内存并分配
设置new-handler
定义一个myNewHandler函数,当new操作符无法分配内存的时候,就会调用该函数;然后将这个函数设置为全局的new-handler,如果new失败的话,就会调用myNewHandler函数,输出错误信息并终止程序
#include <iostream>
#include <new> // std::set_new_handler
#include <cstdlib> // std::abort
// 自定义 new-handler 函数
void myNewHandler() {
std::cerr << "内存分配失败,正在尝试释放内存..." << std::endl;
std::abort(); // 终止程序
}
int main() {
// 设置自定义的 new-handler
std::set_new_handler(myNewHandler);
try {
// 尝试分配一大块内存,故意触发内存分配失败
int* bigArray = new int[100000000000L]; // 调试失败,但是核心意思不变
} catch (const std::bad_alloc& e) {
std::cerr << "捕获到 bad_alloc 异常:" << e.what() << std::endl;
}
return 0;
}
分析new-handler的行为
如果没有设置new-handler的话,当new无法分配内存的时候,就会抛出bad_alloc异常;相反如果设置的话,那么在内存分配失败的时候调用new-handler,如果new-handler返回,那么new就会再次尝试分配新的内存
new-handler不应该再次的调用new,因为这样可能会引起无限递归,一般情况下应该使用其做一些内存释放操作、记录日志或者终止程序
总结反思
- 优先使用智能指针和容器,减少手动管理内存
- new-handler一般适合在处理内存不足的情况
- 避免new-handler中再次调用new,因为可能会导致递归调用,最终导致堆栈溢出
6. placement new 和 placement delete
placement new
使用placement new实现在预先分配的内存块上直接构造对象,而不是重新分配内存。其中placement new需要传递一个指向已分配的指针。
void* operator new(std::size_t, void* p) noexcept {
return p;
}
一般在使用内存池中可以减少频繁的内存分配开销,同时可以使用其来避免重复分配和释放内存。还可以使用其实现内存的自动对齐。
注意placement new是不会自动调用析构函数的,需要自己手动调用析构函数
#include <iostream>
#include <new>
class MyClass {
public:
MyClass(int x) : value(x) {
std::cout << "构造 MyClass,值:" << value << std::endl;
}
~MyClass() {
std::cout << "析构 MyClass,值:" << value << std::endl;
}
private:
int value;
};
int main() {
// 预先分配内存
char buffer[sizeof(MyClass)];
// 使用 placement new 在 buffer 上构造 MyClass 对象
MyClass* obj = new (buffer) MyClass(42);
// 手动调用析构函数,因为没有使用 delete
obj->~MyClass();
return 0;
}
placement delete
主要作用就是防止使用placement new造成内存泄漏
void operator delete(void* p, void*) noexcept {
std::cout << "调用 placement delete" << std::endl;
}
总结与反思
- 只有在对性能有高要求的时候,才需要使用placement new创建内存,例如内存池或者自定义内存对齐
- 使用placement new的时候要确保和placement delete配合使用,从而确保在异常情况下可以正确释放内存
- 多数情况还是首选智能指针