《Effective C++》学习笔记:条款02

条款02:尽量以 const, enum, inline替换 #define(Prefer consts, enums, and inlines to #defines)

核心思想

  • 宁可以编译器替换预处理器
  • 优先选择编译器而不是预处理器
  • 让编译器而非预处理器处理更多工作

定义宏常量:(普通)常量

实现1 (#define


实现2 (const


命名规范

  • #define :使用全大写命名 ASPECT_RATIO

  • const :使用大驼峰或小驼峰 命名(遵循C++变量命名规则)

    • 大驼峰:AspectRatio
    • 小驼峰:aspectRatio
      本质区别
  • #define预处理指令,属于预处理器层面的机制,不被视为C++语言本身的一部分。

  • constC++ 关键字,用于定义语言级别的常量,受编译器理解和管理。
    作用机制

  • #define预处理阶段(编译之前) 进行纯文本替换

    • 预处理器会将源代码中所有出现的ASPECT_RATIO直接替换为1.653。
  • const编译阶段 真正定义 了一个具有确定类型的常量对象

    • AspectRatio是一个double类型的常量,并赋值为1.653。
      作用范围
  • #define

    • 不加控制 **:**宏从定义点开始,在整个源文件中始终有效
    • 加以控制:可通过#undef取消宏定义
  • const无需控制,遵循C++的作用域规则,仅在定义的作用域内有效
    编译阶段(类型检查)

  • #define没有类型信息 ,编译器不进行任何类型检查 ,所以在操作上更容易出现错误。

    • 当ASPECT_RATIO用于整数运算时,可能不会得到预期的结果。
  • const有完整的类型信息 ,编译器进行类型检查和类型转换
    编译阶段(错误信息)

  • #define :ASPECT_RATIO记号名称不会被编译器看到 (在编译器处理前已被预处理器展开并移除),也就不会进入记号表 / 符号表(symbol table 。因此,当使用该常量但触发编译错误时,错误信息中往往只会出现展开后的字面值 (例如 1.653),而不是 ASPECT_RATIO 本身。这很容易令人困惑:如果ASPECT_RATIO定义在某个并非由你编写的头文件中,你很可能对这个1.653毫无印象,更不知道它从何而来。结果就是,你不得不花费额外的时间去追踪这个神秘数字的来源,徒增调试成本。

  • const :AspectRatio常量对象会被编译器看到 ,也就会进入记号表 / 符号表(symbol table ,错误信息中可以直接定位到AspectRatio。
    调试阶段

  • #define :无法被调试器识别和追踪,调试时无法查看宏值 (注解:通过#define定义的常量不具备类型信息,故此处未提及类型)。而且,如果宏定义中存在错误,比较难排查到具体错误。

  • const :可以被调试器识别和追踪,调试时可以方便查看变量值和类型
    目标代码

  • #define:由于预处理器会无差别地进行文本替换,同一字面值(如 1.653)可能在目标代码中出现多次,导致生成代码体积增大。

  • const:编译器可以对常量进行优化(如合并、内联或放入只读区),不会出现宏那样的重复展开问题。
    使用场景

  • #define既可以用于定义常量也可以用于定义宏函数

  • const通常用于定义常量
    小结:在定义常量方面,const更具优势,特别是在类型安全和可调试性方面。因此,在大部分情况下,推荐使用const而不是#define。另外,现代C++ 更倾向于使用constexpr关键字来定义编译时常量,它比const更有优势,因为它保证了表达式尽量在编译期计算,代码性能更优。

定义宏常量:字符串常量

实现1 (#define


实现2 (const :基于char* 的字符串常量(C 风格

  • 由于常量定义式 通常会被放在头文件 中(以便多个源文件共享),因此在定义"基于指针的字符串常量"时,必须同时保证:必须写两次 const
    • 指针所指向的内容不可修改 → "所指对象"是const
    • 指针本身也不可修改 → "指针自身"是const


实现3 (const :基于std::string 的字符串常量(C++ 风格

  • std::string通常比传统的char*字符串更加安全和易用(只需要一次 const

定义宏常量:类专属常量

实现1 (#define :#define无法定义类专属常量

  • 首先,宏不遵循作用域规则。一旦某个宏被定义,它在后续的整个编译过程中都会有效,除非在某处通过#undef取消定义。

  • 其次,宏无法提供任何封装性 ,即不存在所谓的 private #define (无法使用public/protected/private修饰#define)。
    实现2 (const

  • 首先,const遵循C++的作用域规则。当在类内定义const变量时,其作用域便是所属类。

  • 其次,const成员变量可以提供封装性。

  • 设计思路

    • 要将常量的作用域限制在类内 ,就必须把它设为类成员
    • 要确保常量最多只有一份实体 ,就必须把它设为静态成员

实现2.1in-class 类内初值设定(将初值放在声明式)

  • 头文件(.h :提供声明式 ,并设定初值
  • 实现文件(.cpp :提供定义式
  • 注1 (定义式的必要性)一般情况下 ,C++规定必须 为所使用的任何东西提供定义式特殊情况下类专属的静态整数( int, char, bool )常量
    • 不获取地址 :可以声明并使用它们,而无须提供定义式
    • 获取地址 编译器坚持要定义式必须提供定义式
    • 对于对象而言,定义式是编译器为该对象分配内存的地点。
  • 注2 (定义式的位置) :定义式必须放在实现文件 中,不能放在头文件 中(否则重复 / 多重定义)。
  • 注3 (定义式的初值设定) :类常量已在声明时设定初值 (编译期),因此定义时不可以再设初值 (否则重复初始化)。

实现2.2out-class 类外初值设定(将初值放在定义式)

  • 问题引入 :以下两种情况,"in-class初值设定"将不适用,此时只能使用"out-class初值设定"。
    • 情况1 :旧式编译器不支持"in-class初值设定"的语法
    • 情况2只允许整数常量"in-class初值设定"
  • 头文件(.h :提供声明式
  • 实现文件(.h :提供定义式 ,并设定初值
  • 注意:"out-class初值设定"无法提供编译期常量,也就无法用于定义数组大小,因此本例也不存在"int scores[NumTurns];"这句代码。换言之,以下代码将无法通过编译。


实现3 (enum

  • 问题引入 :当你在类的编译期间需要一个类常量值时,例如数组声明中(编译器要求数组大小必须在编译期确定 ),通常会使用static整型类常量来完成这一需求。但如果编译器(尽管这是不符合标准的行为)不允许对"static整数型类常量"进行in-class初值设定,应该怎么办?换言之,当需要编译期常量(比如数组大小等)又无法通过" in-class 初值设定"提供时,应该怎么办?
  • 解决方案 :采用**"** enum hack **"**作为替代方案
  • 理论依据枚举类型的枚举值在编译期即为常量表达式 ,并且可以当作int使用。因此,可以通过在类内定义一个枚举值来获得一个编译期常量 ,从而满足**"数组大小等必须在编译期确定"**的场景需求。
  • 深入理解enum hack (行为模式) :enum hack在某些行为上更像 #define而不像 const ,而这有时正是我们所需要的。若设计目标是不希望别人获得指向某个整数常量的pointer或reference,那么使用enum可以自然地施加这种约束。此外,尽管优秀的编译器通常不会 为"整数型const对象"分配额外的存储空间 (除非你创建了指向它的pointer或reference),但较差的编译器可能仍会为其分配内存 ------而这往往并不是你想要的行为。相比之下,enum和#define一样,不会导致不必要的内存分配。
    • 取得一个const 对象 的地址是合法
    • 取得一个enum 成员 的地址是不合法
    • 取得一个**#define** 常量 的地址是不合法
  • 深入理解enum hack (实用主义) :许多既有代码中都使用了enum hack,因此你必须能够识别并理解它。事实上,"enum hack"是模板元编程 TMP (见"条款48")的基础技术之一。在早期C++中,enum hack是实现编译期整数计算的重要手段。

定义宏函数(误用/滥用情况)

实现1 (#define

  • 原理 :预处理器在预处理阶段对宏进行展开,即"纯文本替换"
  • 宏函数形似函数 的宏、带着宏实参的宏
  • 优点(效率高)不会招致函数调用带来的额外开销(将函数调用的开销从"运行期"提前到"预处理期")
  • 缺点1 (不易使用) :必须为所有实参 加上小括号 ,为整个表达式 加上小括号 ,否则结果不可预知(运算优先级可能出错 )。
    • 错误实现 :#define SQUARE(x) x * x
      • SQUARE(a + b) // 展开为 a + b * a + b(非预期)
    • 正确实现 :#define SQUARE(x) ((x) * (x))
      • SQUARE(a + b) // 展开为 ((a + b) * (a + b))(预期)
  • 缺点2 (结果不可预知) :尽管为所有实参 加上小括号 ,结果也会不可预知(参数多次被求值问题)(如下图所示,调用f之前,变量a的递增次数竟然取决于"它被拿来和谁比较"。)


实现2 (inline :template inline函数

  • 原理 :编译器在编译阶段将"函数调用"替换为"函数本体"
  • 优点1 (效率高) :可获得类似于宏函数效率(无函数调用开销)(将函数调用的开销从"运行期"提前到"编译期")
  • 优点2 (更易使用):无需在函数本体中为参数手动加上小括号,运算优先级问题由语言保证,语法自然、清晰
  • 优点3 (结果可预知) :可获得类似于一般函数可预料行为 (无需担心参数被多次求值问题)和类型安全性
  • 优点4 (遵守作用域与访问控制) :由于callWithMax是真正的函数,它遵守作用域访问规则 ,例如可以写出"class private inline函数"。一般而言,宏无法完成此事。

小结

有了const、enum和inline之后,我们对预处理器(尤其是#define)的依赖已经大幅降低,但还无法完全摆脱它#include 依然不可或缺,而**#ifdef / #ifndef** 也继续在条件编译中发挥重要作用。虽然预处理器还不到彻底退出舞台的时候,但我们应当有意识地减少对它的使用,让它"多休息一会儿"。
请记住

  • 单纯常量:最好以const对象或enum替换#define
  • 形似函数的宏:最好以inline函数替换#define
相关推荐
tankeven2 小时前
HJ84 统计大写字母个数
c++·算法
㓗冽2 小时前
阵列(二维数组)-基础题79th + 饲料调配(二维数组)-基础题80th + 求小数位数个数(字符串)-基础题81th
数据结构·c++·算法
默凉2 小时前
C++ 编译过程
开发语言·c++
ArturiaZ2 小时前
【day28】
开发语言·c++·算法
我 see your eyes2 小时前
CLA_TASK 任务的理解
c语言·c++·dsp开发
闻缺陷则喜何志丹3 小时前
【状态压缩动态规划】P8733 [蓝桥杯 2020 国 C] 状态压缩动态规划|普及+
c++·算法·蓝桥杯·动态规划·洛谷
alanesnape3 小时前
Valgrind 测试详解--检测内存泄漏的好工具
c语言·c++·算法
近津薪荼4 小时前
优选算法——前缀和(6):和可被 K 整除的子数组
c++·算法
白太岁4 小时前
通信:(2) TCP/UDP、流量/拥塞控制、ARP 与 Socket 应用
网络·c++·tcp/ip·udp