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

【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配合使用,从而确保在异常情况下可以正确释放内存
  • 多数情况还是首选智能指针


http://www.kler.cn/a/391358.html

相关文章:

  • web-密码安全口令
  • 电脑出现 0x0000007f 蓝屏问题怎么办,参考以下方法尝试解决
  • opencv中的各种滤波器简介
  • GMSSL的不同python版本
  • GitCode 光引计划投稿|JavaVision:引领全能视觉智能识别新纪元
  • 详细ECharts图例3添加鼠标单击事件的柱状图
  • css2D变换用法
  • SobarQube实现PDF报告导出
  • Linux——基础指令2 + 权限
  • [SaaS] 数禾科技 AIGC生成营销素材
  • 35.Redis 7.0简介
  • ensp中配置ISIS以及ISIS不同区域的通信
  • pytorch torch.randint
  • 解决SLF4J: Class path contains multiple SLF4J bindings问题
  • 丹摩征文活动 | 搭建 CogVideoX-2b详细教程:用短短6秒展现创作魅力
  • labview实现上升沿和下降沿
  • 【海外SRC漏洞挖掘】谷歌语法发现XSS+Waf Bypass
  • SpringBoot下Bean的单例模式详解
  • Spring Boot编程训练系统:开发中的挑战与解决方案
  • PVE纵览-从零开始:了解Proxmox Virtual Environment
  • C++初阶——list
  • 【MySQL】MySQL函数之JSON_EXTRACT
  • python机器人Agent编程——使用swarm框架和ollama实现一个本地大模型和爬虫结合的手机号归属地天气查询Agent流(体会)
  • CKA认证 | Day2 K8s内部监控与日志
  • Rust where子句(用于指定泛型类型参数约束、泛型约束、泛型类型约束)
  • npm list @types/node 命令用于列出当前项目中 @types/node 包及其依赖关系