C++ 基础与原生互操作(二):类型、指针、引用与生命周期

阅读 C++ SDK 头文件时,最容易卡住的不是 iffor,而是 const char*void**T&、数组长度和谁负责释放内存。本篇不把指针讲成玄学,而是围绕三个问题展开:对象在哪里、一个表达式指向谁、对象能活多久。

1. 类型决定可执行的操作

C++ 内置了整数、浮点、字符和布尔类型,也允许定义枚举、结构体和类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <cstdint>

enum class DeviceState : std::uint32_t
{
    disconnected = 0,
    ready = 1,
    running = 2,
    faulted = 3
};

struct Measurement
{
    std::uint64_t sequence;
    double value;
    DeviceState state;
};

std::uint32_tstd::uint64_t 等固定宽度整数在平台提供对应精确宽度类型时可用,适合协议字段和经过设计的 ABI。普通 int 至少为 16 位,但标准不承诺它在所有平台都恰好是 32 位。跨语言边界不能只凭本机经验猜大小。

sizeof(T) 返回类型或对象占用的字节数,但结构体大小不一定等于各字段大小简单相加:编译器可能为对齐插入填充。字段类型一致也不代表不同编译器选项下的布局一定兼容。

2. 值、引用和指针

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void increment_value(int value)       // 得到副本
{
    ++value;
}

void increment_reference(int& value)  // 借用原对象,可修改
{
    ++value;
}

void increment_pointer(int* value)    // 指向对象,也可以为空
{
    if (value != nullptr)
    {
        ++*value;
    }
}

调用方式如下:

1
2
3
4
int count = 1;
increment_value(count);       // count 仍为 1
increment_reference(count);   // count 变为 2
increment_pointer(&count);    // count 变为 3
  • T 按值传递,函数获得一个参数对象;复制可能被移动或省略规则优化,但接口语义仍是值;
  • T& 是引用,必须绑定到有效对象,常表示非空借用;
  • T* 是指针,保存地址,可用 nullptr 表示没有对象;
  • &object 取得地址,*pointer 解引用并访问所指对象。

引用不是“更安全的指针”这么简单。引用和指针都可能在目标对象生命周期结束后悬空;区别主要体现在语法和接口表达力,而非自动生命周期管理。

3. const 要看修饰的是谁

1
2
3
4
int count = 1;                          // 沿用上一节的计数变量
const int* pointer_to_const = nullptr; // 不能通过指针修改 int
int* const const_pointer = &count;     // 指针本身不能改指向
const int* const both = &count;        // 两者都不能改

函数参数中最常见的是:

1
double average(const double* values, std::size_t count);

它表示函数只读 values 指向的元素,但没有说明指针能否为空、实际有多少元素、内存由谁拥有。count 和指针之间的对应关系只是接口约定,调用者必须同时满足。

在纯 C++ 内部代码中可使用 std::span<const double> 把“指针 + 长度”组合为一个非拥有视图:

1
2
3
#include <span>

double average(std::span<const double> values);

不过 std::span 是 C++ 标准库类型,不应直接放进供 P/Invoke 使用的 C ABI。跨边界时仍需展开为基础类型。

4. 数组、容器与字符串

1
2
3
4
5
6
7
#include <string>
#include <string_view>
#include <vector>

std::vector<double> samples { 1.2, 2.4, 3.6 };
std::string owned_name = "camera-1";
std::string_view borrowed_name = owned_name;
  • std::vector<T> 拥有一段连续元素,可自动管理容量和释放内存;
  • std::string 拥有字符序列;
  • std::string_view 只是字符视图,不拥有底层数据;
  • 原生数组 T[N] 的长度是类型的一部分,但传给多数函数后常退化为指针,长度信息不会随指针自动传递。

string_view 很轻量,但必须比它观察的数据活得短。下面的函数会返回悬空视图:

1
2
3
4
5
std::string_view broken_name()
{
    std::string temporary = "camera-1";
    return temporary; // 返回后 temporary 被销毁
}

动态库也不能把内部临时 std::stringc_str() 返回给 C# 后继续假设它永远有效。字符串编码、终止符、缓冲区容量和有效期必须成为接口契约的一部分。

5. 存储位置不等于生命周期模型

初学资料常把内存简单分成“栈”和“堆”,但 C++ 标准层面更重要的是存储期和对象生命周期:

1
2
3
4
5
6
7
8
9
int global_value = 1;       // 静态存储期
thread_local int last_code; // 线程存储期

void process()
{
    int local_value = 2;             // 自动存储期
    auto dynamic_value = new int(3); // 动态存储期
    delete dynamic_value;
}

局部对象通常由实现放在调用栈,但优化器可以改变具体存放方式;new 创建的对象具有动态存储期,必须由匹配的所有者释放。现代 C++ 业务代码一般不应裸写上面的 new / delete,而应优先使用局部对象、容器和智能指针。

对象生命周期结束后,之前指向它的指针不会自动变成 nullptr。继续解引用就是未定义行为:程序可能崩溃、貌似正常,或在优化构建中表现完全不同。

6. 未定义行为为什么危险

常见来源包括:

  • 数组或缓冲区越界;
  • 解引用空指针或悬空指针;
  • 读取未初始化的基础类型;
  • 有符号整数溢出;
  • 数据竞争;
  • 用不匹配的方式释放内存。

“未定义”不是“标准规定必须抛异常”。它表示程序已经违反语言要求,标准不再约束后续行为。调试版能运行不能证明代码正确,跨编译器、跨优化级别或升级工具链后尤其容易暴露问题。

MSVC 可使用 AddressSanitizer 帮助发现部分内存错误:

1
cl /std:c++20 /EHsc /fsanitize=address sample.cpp

GCC 和 Clang 常用:

1
g++ -std=c++20 -fsanitize=address,undefined -fno-omit-frame-pointer sample.cpp

Sanitizer 是运行时检测工具,只能覆盖实际执行到的路径,不能替代正确的所有权设计和测试。

7. 怎样读 SDK 函数签名

假设厂商头文件给出:

1
2
3
4
5
int read_frame(
    device_handle* device,
    std::uint8_t* buffer,
    std::size_t capacity,
    std::size_t* bytes_written);

逐项追问:

  1. device 是否允许为空,由谁创建和销毁?
  2. buffer 是调用者提供的可写内存,还是函数会替换指针?
  3. capacity 以字节还是元素计数?
  4. bytes_written 是否必填,失败时是否仍会写入?
  5. 返回 int 是状态码还是实际长度?错误码如何解释?
  6. 函数返回后,库是否仍保留任何指针?是否允许并发调用?

头文件只描述类型,未必描述完整契约。P/Invoke 声明必须以供应商头文件和文档为依据,不能只看函数名字推断。

8. 跨 ABI 时需要更保守

在 DLL 边界应谨慎对待这些类型:

  • long:Windows 上通常为 32 位,许多 64 位 Unix 平台上为 64 位;
  • wchar_t:Windows 常为 16 位,许多 Unix 平台常为 32 位;
  • bool:不要假设与 C# 默认 P/Invoke 布尔封送布局相同;
  • size_t:随进程位数变化,C# 通常用 nuint 对应;
  • 指针和句柄:随进程架构变化,C# 通常用 nintSafeHandle
  • C++ 标准库类型:std::stringstd::vector 等布局属于具体实现的 ABI,不直接暴露给 P/Invoke。

更稳妥的公共接口使用固定宽度整数、明确的指针 + 长度、显式编码和不透明句柄。C# 侧的具体类型映射可继续阅读 C# 原生互操作与设备 SDK(二):C# 类型与 C ABI

9. 本篇小结

值表达独立对象,引用和指针通常表达借用或间接访问,但它们不会自动管理目标生命周期。现代 C++ 内部优先让容器和资源管理对象持有内存;进入 ABI 边界后,再把接口收窄为尺寸、编码和所有权都明确的基础类型。

参考资料

Licensed under CC BY-NC-SA 4.0