从 C 到 C++:引用、const 与重载
环境:Windows 11 x64,MinGW-w64 GCC 13.1.0,分别用
gcc -std=c17与g++ -std=c++17编译,所有差异都是同一台机器上对比出来的。
一、先做一件事:同一份代码编译两遍
#include <stdio.h>
const int gconf = 7;
static int counter(void) { static int n = 0; return ++n; }
int main(void) {
printf("sizeof('A') = %d\n", (int)sizeof('A'));
printf("counter() 三次 = %d%d%d\n", counter(), counter(), counter());
}
gcc -std=c17 → sizeof('A') = 4 counter() 三次 = 321
g++ -std=c++17 → sizeof('A') = 1 (其余相同)
'A' 在 C 里是 int,在 C++ 里是 char。这一条本身无害,但它是”C++ 不是 C 的超集”的第一个信号——下面每一条都用同样的方法验证。
二、f() 在两种语言里意思完全不同
| 写法 | C17 | C++17 |
|---|---|---|
int f() | 不描述参数,调用 f(1,2) 编译器不管 | 等价于 int f(void),f(1,2) 直接报错 |
int f(void) | 无参 | 无参 |
int f() { return 1; }
int main() { return f(1, 2); }
error: too many arguments to function 'int f()'
而 C 侧(第九节量过)同样的调用编译通过、跑出垃圾值。所以:
- C 代码迁到 C++,第一件事是把所有
f()声明改成f(void),否则 C++ 会突然报一堆”参数过多”。 - 反过来不成立:C++ 里写
f()是安全的(就是无参),C 里不安全。 - C 项目至少该开
-Wstrict-prototypes,把这类漏网之鱼抓出来。
三、const 的链接性:C++ 默认内部,C 默认外部
同一份”两个编译单元都定义 const int shared”的代码:
/* a.c / d6.cpp */ const int shared = 1;
/* b.c / d6b.cpp */ const int shared = 2;
gcc(C) → ld.exe: multiple definition of `shared' 链接失败
g++(C++)→ 链接 exit=0 各自一份,互不干涉
C 标准规定文件作用域的 const 对象仍是外部链接;C++ 规定 const 全局默认内部链接(相当于隐式 static)。这意味着:
- 从 C++ 移植到 C 的公共头文件,
const int kMax = 10;会突然报重复定义 → 要么加static,要么#define/enum。 - 反过来,C++ 里想跨 TU 共享一个常量,必须
extern const int kMax;声明 + 单一处定义,否则每个.cpp拿到的是自己的副本(值一样时看不出问题,取地址比较时才发现”为什么两个指针不相等”)。
四、枚举:C 的弱与 C++ 的强
enum E { E0, E255 = 255 }; /* C 风格 */
enum class EC : uint8_t { A, B }; /* C++11 作用域枚举,指定底层类型 */
sizeof(E)=4 sizeof(EC)=1
| 特性 | C 的 enum | C++ 的 enum class |
|---|---|---|
| 大小 | 恒为 int 大小(4),与最大值无关 | 由你指定的底层类型决定(这里 1 字节) |
| 名字作用域 | 污染外层作用域(E0 直接可见) | 必须 EC::A |
隐式转 int | 可以 | 不可以 |
| 取值检查 | 无(第九节实测 enum Color c = 42; 合法) | 无,但转换必须显式 |
enum class Color { Red, Green };
int x = Color::Red;
error: cannot convert 'Color' to 'int' in initialization
需要数值时显式 static_cast<int>(Color::Red)。新代码一律用 enum class:它不污染命名空间、不会和整数混用、大小可控。唯一要注意的是它不能隐式当数组下标/位标志用,位标志就老老实实 enum class Flags : uint32_t 再重载 operator|。
顺带一组实测宽度(MinGW 走 MSVC ABI):
sizeof(std::nullptr_t)=8 sizeof(NULL)=8
wchar_t=2 char16_t=2 char32_t=4
NULL 在 C++ 里是 0L(long,8 字节),这也是为什么 f(NULL) 会选中 f(long) 而不是 f(int*) 重载——C++ 里永远写 nullptr。nullptr 的类型是 std::nullptr_t,只向指针隐式转换,不会和整数重载打架。
五、初始化:C++ 更严,且顺序有讲究
int i{3.5}; // 窄化
struct P { int x; int y; };
P p{ .y = 2, .x = 1 }; // 乱序指定初始化
error: narrowing conversion of '3.5e+0' from 'double' to 'int' [-Wnarrowing]
error: designator order for field 'P::x' does not match declaration order in 'P'
同一段乱序指定初始化在 C 里是合法的(第四节实测 designated = 0 7 0 9)。三条 C++ 规矩:
- 花括号
{}禁止窄化,=或()允许。这正是推荐统一用{}的理由——它把丢精度变成编译错误。 - 指定初始化器(C++20 起)必须按声明顺序,且不能混用新旧写法。所以别指望把 C 的配置表写法直接搬过来。
int i;(默认初始化)在函数内是未初始化,int i{};才是零初始化。类成员同理,别忘了在初始化列表里给全。
六、引用:不是”常量指针的语法糖”
int a = 1, b = 2;
auto &r = a; r = b; // 改的是 a,不是让 r 改绑
const int &cr = a + b; // const 左值引用可绑临时量
int &&rr = a + b; // 右值引用
auto byval = a; byval = 99; // auto 推导丢弃引用
a=2 r=2 cr=4 rr=4 sizeof(r)=4
改 byval 之后 a=2 byval=99
四条语义:
-
引用不能改绑:
r = b是”把 2 赋给 a”,所以a变成 2。想换目标只能用指针。 -
sizeof(r)是被引用对象的大小(4),不是指针的 8——但底层实现通常就是个指针,这是”语义与实现分离”的典型。 -
const左值引用可以绑临时量,并且延长其生命周期到引用的生命周期结束(cr=4就是这么来的)。非 const 左值引用绑临时量是错误。这是 C++ 里少数真正”魔法”的规则,也是const T&参数能接受字面量的原因。 -
auto推导丢弃引用和 const:auto byval = a;得到的是副本。想要引用必须写auto &;想要 const 引用写const auto &。这条规则的著名受害者是std::vector<bool>:std::vector<bool> v(10); for (auto x : v) { } // x 是 bool,每次拷贝代理对象——能跑但慢 for (auto &x : v) { x = true; } // 改的是代理,行为对但语义微妙vector<bool>的元素不是bool&而是代理对象,auto&绑的是临时的代理,生命周期只到这条语句结束。想写通用代码就用auto&&。
七、重载决议:优先级不是你想的那样
void f(int); void f(long); void f(unsigned);
void g(double); void g(float);
short s = 1;
f(s); f(1u); f(1L); g(1.0f);
int unsigned long float
f(s)(short)→int:整型提升的优先级高于任何转换,short → int是提升,short → long是转换,所以选int。f(1u)→unsigned(精确匹配);f(1L)→long(精确匹配);g(1.0f)→float(精确匹配)。
匹配等级从高到低:精确匹配 → 提升 → 转换 → 省略号匹配。同一等级里出现两个候选,就是歧义,编译错误:
void f(long); void f(float); void f(unsigned long long);
f(1u);
error: call of overloaded 'f(unsigned int)' is ambiguous
note: candidate: 'void f(long int)'
unsigned int 到 long、float、unsigned long long 全都是”转换”,没有谁更优。同理这两个也会歧义:
f(1.0) 在 {long, float, unsigned long long} 中 → 歧义
g(1) 在 {double, float} 中 → 歧义
实用结论:给函数加一个”看起来更精确”的重载,可能让原本能编译的调用点突然全部歧义。接口演进时优先用 static_assert + 明确类型,或者干脆改名(_ex、_2),别乱加重载。
八、构造与析构:顺序写死在标准里
struct Base { Base() { printf("Base() "); } virtual ~Base() { printf("~Base() "); } };
struct Derived : Base { Derived() { printf("Derived() "); } ~Derived() override { printf("~Derived() "); } };
Derived d; // 栈对象
Base *p = new Derived(); // 通过基类指针删除
delete p;
Base() Derived() Base() Derived() ~Derived() ~Base() ~Derived() ~Base()
三条铁律:
- 构造:基类先于派生类;成员按声明顺序构造(不是初始化列表的书写顺序——这条最容易踩,成员初始化列表写反顺序时 GCC 会警告
-Wreorder)。 - 析构:派生类先于基类,正好相反。
- 通过基类指针
delete时,基类析构必须是virtual,否则派生类析构不执行(未定义行为)。上面~Derived()能打印出来,正是因为Base::~Base()加了virtual。
代价是每个多态对象都要带一个虚表指针(本机 8 字节)。所以:不需要多态的基类不要写虚析构,需要多态的必须写。
九、这一节的自查清单
- 迁移 C 代码到 C++ 时,先把所有
f()声明改成f(void) - 跨 TU 共享的常量显式写
extern const,否则默认内部链接 - 新枚举一律
enum class,需要时指定底层类型 - 一律用
{}初始化,让窄化变成编译错误 - 永不使用
NULL,只用nullptr - 理解
auto丢弃引用/const,需要引用写auto&或auto&& - 不往既有接口上加同等级重载,避免制造歧义
- 多态基类必须有
virtual析构;非多态类不要加 - 成员初始化列表按声明顺序写,开
-Wreorder - 记住
'A'在 C++ 里是char(1 字节),在 C 里是int
下一节讲类与封装:构造/析构的完整次序、拷贝控制的三法则与五法则、explicit 到底防什么,以及为什么”空的派生类”也要占 8 字节。