现代 C++ 跨平台开发-内存篇:类型转换、重载决议、内存模型

从前些年零星参与 Android/iOS C/C++ 跨平台项目,到 24 年真正从零搭建完整的跨所有主流平台的 C++ 项目,再到去年底进入某老牌软件大厂见识横跨近 30 年的庞大 C++ 项目,对 C++ 跨平台开发有了越来越深刻的认识。

虽然之前也有写一些相关的文章,但大多属于管窥蠡测;这里尝试做一个体系化、结构化的梳理。

涉及的知识点颇多,但每个又都不可或缺,否则就无法全面地认识 C++ 这门既古老而又年轻的系统编程语言,以及诸多内存问题的来龙去脉。

借用我对 C++ 之父的一句经典言论的演绎开篇:

C makes it easy to shoot yourself in the foot.
C++ makes it harder, but when you do it blows your whole leg off.
Rust takes away your gun, and gives you a bible.

内容过于庞杂,拆分为一个系列,本文是第 1 篇。

类型系统

类型是内存的“契约”:
内存只是字节,类型赋予其意义(没有类型的 void*,就不知道如何解释它)。

指针:内存地址的抽象

指针

指针 = 地址 + 偏移(类型信息)

  • char*, short*, int* 虽指向同一地址,但解引用或 ++/-- 时需考虑类型信息确定偏移量;

数据指针与函数指针

可能很多 C 语言教材描述,函数指针指向函数入口地址。虽然都是“地址”,但它跟普通数据指针有显著差异。

  • 数据指针:位于数据段,指向的位置存的是数据,可读写;

  • 函数指针:

    • 位于代码段,指向的位置存的是指令,只读、可执行;

    • 成员函数指针,相比普通函数/静态成员函数多了 this 指针参数和额外信息(指针偏移、虚函数标记等);

指针与数组

另一个容易被教材误导的例子:将数组名等效为首元素地址。

  • 类型不同:数组包含长度信息;

  • 行为不同:sizeof()alignof() 都是针对整个数组,而不是首元素;

  • 数组名不可修改,指针可以;

  • 数组作为参数,会自动退化为指向首元素的指针。

引用

  • 内部实现也是指针,只是做了编译期限制(必须初始化、不允许重新赋值);

nullptr

  • C 语言 NULL 定义为 ((void*)0),C++ 中就是魔法数 0

  • 函数重载和类型推导时会出问题,无法区分指针和数字。

  • 实现:

const class nullptr_t {
public:
    template<class T>inline operator T*() const { return 0; } //实现解引用
private:
    void operator&() const; //禁止取地址
}

指针别名限制

两个指针不会内存重叠(指向同一内存),否则就是 UB;便于编译器做向量化(SIMD)和指令预取等优化;

C
  • 提供了 restrict 关键字,限制同类型指针;

  • 嵌套调用等场景会有问题;

C++
  • 默认并不支持 restrict 关键字,但各大编译器提供了类似扩展;

  • 不仅限于同类型指针,非相关类型指针也不行(int*/char*/std::byte* 除外,但 int*/float* 不行);

Rust 的借用检查机制天然为编译器提供了“无别名”(no-aliasing)的强保证,从而能安全、激进地进行向量化(SIMD)等高级优化。
不少 C 项目被 Rust 重写后号称性能提升,很大程度正是基于此。

安全地取地址

C++ 支持重载 operator&,而 std::addressof() 则可以绕过重载逻辑、安全地获取对象地址:

  • 通常会内联优化,零开销;

  • 支持数组和函数;

  • 不能用于临时对象(没有稳定地址);

template <typename T>
void reset_in_place(T& obj) {
    std::destroy_at(std::addressof(obj));
    std::construct_at(std::addressof(obj));
}

地址相关数值类型

uintptr_t

表示指向 void* 的地址,绝大多数情况下优先使用它(例如跨平台场景用作句柄);

intptr_t

如果涉及到地址的算术运算,可能产生负数,则需要使用它;

ptrdiff_t

当两个指针地址明确位于同一数组或对象,计算地址差值优先考虑用它;

std::array<int, 5> a= {1, 3, 5, 7, 9};
auto *p = a.data();
auto *p3 = &a[3];
std::ptrdiff_t diff = p3 - p;
std::cout << "Distance: " << diff << std::endl;

类型转换 - 改变对内存的解释方式

static_cast()

  • 常用于有关系的类型之间转换,编译器会校验,绝大多数情况适用;

  • 用于基本类型间的转换,不能用于基本类型指针间的转换 ;

  • 有继承关系类对象间的转换和类指针间的转换(如 CRTP);

  • 用于左右值引用转换;

  • T* <-> void*(有类型检查,比 reinterpret_cast() 更安全);

dynamic_cast()

  • 有继承关系的类指针间的转换;

  • 运行时多态,有开销;如果确认类型,直接 static_cast()

  • 具有类型检查的功能,转换失败抛出 std::bad_cast 异常;

reinterpret_cast()

  • 指针间的类型转换,不如 static_cast() 安全,尽量避免;

  • 整数和指针间的类型转换;

  • 可以加上 constvolatile,但不能去掉。

  • 不能用于将 nullptr 转换为其他类型,只能用 static_cast() 转;

const_cast()

  • 仅用于指针或引用;

  • 仅用于去掉 const;(加上不需要 cast);

  • 如果需要多次 cast,一般先用其他 cast,最后 const_cast();

  • 运行时转换,不安全(可能崩溃,或属于未定义行为);

void* <-> 函数指针

  • C++ 严格限制 void* 和函数指针的转换,直接转换会警告(reinterpret_cast 也不行);

  • 如果确实要转换,可通过 uintptr_t 中转(绕过编译器检查),但仅限于普通函数和静态成员函数。

普通成员函数包含额外信息,一般远大于 void* 的 8 字节;
C++23 的 Deducing this 只是语法糖,并没有抹平它跟普通函数在内存布局和 ABI 方面的鸿沟。

void fv() {
    std::cout << "Hello World!" << std::endl;
}

using VoidFuncT = void(*)();

int main() {
    // 函数指针 -> void*
    // void* ptr = (void*)fv; 
    void* ptr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(fv));

    // void* -> 函数指针
    // auto func = (VoidFuncT)ptr;
    auto func = reinterpret_cast<VoidFuncT>(reinterpret_cast<uintptr_t>(ptr));

    func();
    return 0;
}

模板元编程场景的类型标识处理

编译时行为,安全高效。

const 标识

仅处理顶层 const 标识。

  • std::is_const<T>();

  • std::add_const<T>();

  • std::remove_const<T>();

volatile 标识

  • std::is_volatile<T>();

  • std::add_volatile<T>();

  • std::remove_volatile<T>();

引用标识

  • std::is_reference_v<T>();

  • std::is_lvalue_reference<T>();

  • std::is_rvalue_reference<T>();

  • std::add_lvalue_reference<T>();

  • std::add_rvalue_reference<T>();

  • std::remove_reference<T>();

std::decay_t()

完全模拟函数按值传参过程的类型退化。

  • 移除(顶层)constvolatile、引用标识;

  • 数组类型退化为指针类型;

  • 函数签名退化为函数指针类型;

std::remove_cvref(C++20)

安全地获取裸类型。

  • 仅移除(顶层)constvolatile、引用标识,不搞类型退化。

重载决议 - 隐式类型转换优先级

精确匹配

  • 类型完全相同;

  • 加上 const/volatile

  • 数组 -> 指针;

  • std::function -> 指针;

类型提升

  • bool/char/short -> int

  • float -> double

标准转换

  • int -> long/float

  • double -> float

  • Derived* -> Base*

  • 指针/数字 -> bool

用户定义的转换

  • 重载构造函数,如 stringconst char* 构造函数;

  • 重载运算符:

class Zero {
public:
    operator int() const {
        return 0;
    };
    operator float() const {
        return 0.0f;
    };
};

void print(int i) {
    std::cout "int:" << i;
}

void print(float f) {
    std::cout "float:" << f;
}

//print(Zero{}); //存在两个相同优先级的用户自定义转换,存在二义性,编译报错
print(static_cast<int>(Zero{}));

RTTI - 运行时类型元数据

编译器遇到 typeid(T)dynamic_cast() 时,自动为类型 T 生成一个 type_info 对象,保存在只读数据段(.rodata / .rdata)中的全局静态对象;
可通过 -fno-rtti 禁用,则所有使用了 typeid(T)dynamic_cast() 的代码都会编译失败;

class type_info {
public:
    virtualtype_info();
    bool operator==(const type_info& rhs) const;
    bool operator!=(const type_info& rhs) const;
    const char* name() const;
private:
    const char* __name; //类型的 mangled name(如"i"表示 int)
}

RTTI is useful only in certain narrow cases… In most cases, the need for RTTI indicates a design flaw.

Google C++ Style Guide 指出,RTTI 某些情况下很管用(单元测试/序列化),但绝大多数时候都应该避免 – 不仅因为其性能开销,更因为其违反软件设计原则:

  • 中心化,违反开闭原则;

  • 抽象依赖具体,破坏分层;

  • 新增类型可能引发意想不到的问题,代码很难维护;

if (typeid(*data) == typeid(D1)) {
    //...
} else if (typeid(*data) == typeid(D2)) {
    //...
} else if (typeid(*data) == typeid(D3)) {
    //...
}

那么如何规避呢?

  • typeid():可将中心化逻辑抽象为虚函数接口,子类负责实现,或直接改为函数重载;

  • dynamic_cast():绝大多数时候如果确认真实类型,直接使用 static_cast()

静态类型信息

类型判断

template 
struct is_floating_point_type {
    static const bool value = false;
};

template <>
struct is_floating_point_type {
    static const bool value = true;
};

template <>
struct is_floating_point_type  {
    static const bool value = true;
};

类型比较

template
struct is_same { 
    static constexpr bool value = false; 
};

// 偏特化:当 T 和 U 相同时
template
struct is_same<T, T> { 
    static constexpr bool value = true; 
};

类型标识

template <typename T>
void* get_type_id() {
    static char token; //针对每个类型特化,生成唯一的静态类型标识
    return &token;
}

template <typename T>
T* any_cast(any* a) {
    // 直接比对类型标识,不依赖 RTTI 的 type_info
    if (a && a->type_id_ == get_type_id<T>()) {
        return static_cast<T*>(a->ptr_);
    }
    return nullptr;
}

内存模型

  • 栈是向下生长的(高地址在上);

  • 函数内局部变量;

  • 效率高:存在寄存器,且有 push/pop 指令;

  • 容量小:每个线程都有固定大小的栈空间;

  • 函数调用前后,由系统自动分配和释放;

堆(自由存储区)

  • 堆是向上生长的(低地址在上);

  • 需手动释放;

  • 几乎无空间限制;

  • 容易产生碎片;

全局/静态区

static

  • 作用于全局变量/函数:

    • 改变链接属性:只能在模块内使用,外部无法通过 extern 访问;

    • 既能实现封装性,也能避免符号冲突;

    • C 语言中,静态变量在代码执行前就会初始化,不能用变量赋值;

    • C++ 更推荐使用匿名 namespace 实现隐藏内部符号;

  • 作用于函数内局部变量:

    • 改变存储属性:保存在静态区的(.data.bss 段),不会随着函数执行完销毁;
  • 作用于 C++ 类:

    • 静态成员变量:存在静态区;

    • 静态成员函数:

      • 跟普通成员函数一样位于 .text 代码段;

      • 没有隐式 this 指针参数,通过类名直接访问,仅能访问静态成员;

      • 不同于全局静态函数的模块内部链接属性,静态成员函数具有外部链接属性;

常量区

const 相关成员一般存储在只读段(.rodata);若某些情况被优化为编译期常量,则不占运行时存储。

const

  • 强调运行期不可修改;

  • const 变量:

    • 局部:

      • 可能被优化为编译期常量,否则放栈上;

      • const_cast() 可能侥幸成功,但属于未定义行为;

    • 全局:

      • 放在只读段;

      • const_cast() 会导致运行时崩溃,因为只读段读写权限不可逾越;

  • 字符串字面量:

    • 相同字符串只存一份;

    • 永远在只读段;

  • 从右向左读( * 读成 pointer to );const 在类型前后是等价的;

char * const cp; //cp is a const pointer to char;
const char * p; //p is a pointer to const char;
char const * p; //同上;constexpr

consexpr

  • 强调编译期可求值,便于编译器深入优化;

  • 修饰变量:

    • 不仅跟 const 一样表示是不可变的,而且是编译期可确定的;

    • 不占用运行时存储。

  • 修饰函数:

    • 若参数编译期可确定,则会在编译期计算出函数返回值;

    • 否则,等同于普通函数;

consteval(C++20)

  • 用于修饰函数,比 constexpr 更严格,强制编译期可求值,否则编译错误;

  • consteval 函数返回值也只能用于 constexpr 变量,而不能用于普通运行期变量赋值;

  • 如果要同时兼容编译期与运行期计算(支持退化),只能改为 constexpr 函数。

consteval unsigned long long hash(std::string_view sv) {
    unsigned long long h = 5381;
    for (char c : sv) h = h * 31 + static_cast<unsigned char>(c);
    return h;
}

int main() {
   //auto h0 = hash("hello"); //编译错误
   constexpr auto h1 = hash("world");
   auto h2 = h1;
   std::cout << h2 << std::endl;
}

编译器默认初始化行为

  • 全局/静态成员,执行零初始化(Zero-initialization):

    • 数值类型置零,指针置空;

    • 调用默认构造函数;

  • 普通局部成员:

    • 数值和指针类型,不做任何处理,也就是垃圾值!

    • 自定义类型,调用默认构造函数(如果构造函数并未初始化成员,则其非静态成员仍是垃圾值);

读取未初始化的垃圾值属于未定义行为,虽不一定会崩溃,但表现不符合预期,可能不同平台/不同编译类型的行为都不一样。

  • 静态局部成员:

    • 在首次调用时初始化;

    • C++11 开始,编译器保证线程安全,可用于实现单例;

为何编译器不保证始终初始化

C++ 的哲学是“零成本抽象”,尤其是默认行为更应该零成本:

for (auto i = 0; i < 1000000; ++i) {
    int temp; // 如果自动初始化为 0,就要执行 100 万次写内存
    // ... 实际马上会被赋值
    temp = compute(i);
}

最佳实践

int a;      // 我知道风险,不要初始化(高性能场景)
int b = 0;  // 我要确定值
int c{};    // C++11 引入的值初始化,安全且明确
std::vector<int> v{};   // 空 vector
MyStruct s{};           // 所有成员零初始化