当前位置: 首页 > news >正文

【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-handlerstd::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);// 手动调用析构函数,因为没有使用 deleteobj->~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配合使用,从而确保在异常情况下可以正确释放内存
  • 多数情况还是首选智能指针


http://www.mrgr.cn/news/70253.html

相关文章:

  • AI 大模型应用:AI开发的捷径工作流模式
  • 18B电阻
  • 世界坐标系、相机坐标系、图像物理坐标系、像素平面坐标系
  • Rocky linux8 安装php8.0
  • ChromeDriver 官方下载地址_测试自动化浏览器驱动
  • 图像处理实验三(Morphological Image Processing)
  • 博图与Factory I/O结合实现运料小车自动往返四次控制
  • json即json5新特性,idea使用json5,fastjson、gson、jackson对json5支持
  • java运行jar包问题总结
  • 从文本到图像:AIGC 如何改变内容生产的未来
  • LeetCode100之螺旋矩阵(54)--Java
  • 【视觉SLAM】Windows下编译Pangolin-0.5,显示SLAM运动轨迹
  • Linux screen和cscope工具使用总结
  • 初次体验Tauri和Sycamore(1)
  • 计算机基础命令行
  • HR怎么看待PMP证书呢?
  • 【真题笔记】21年系统架构设计师案例理论点总结
  • 【C语言指南】C语言内存管理 深度解析
  • C++实现用户分组--学习
  • Python学习从0到1 day26 第三阶段 Spark ④ 数据输出
  • Matlab2022b安装MinGW64
  • 华为eNSP:RSTP
  • 深度解读AI在数字档案馆中的创新应用:高效识别与智能档案管理
  • 合同能源管理服务认证介绍
  • SSD与AI:PBlaze7 7A40实战MLPerf Storage
  • 从零开始使用Intel的AIPC使用xpu加速comfyui