CppCon 2017 学习:API & ABI versioning How to handle compatibility with your C++ libraries

这段内容主要讲的是发布一个库(library)时,维护者需要考虑的一些关键问题,尤其是关于API和ABI的兼容性以及版本管理方面。总结如下:

  1. 库代码归属
    • 如果所有使用者的代码都和库放在同一个仓库里,版本管理不是必须的,但考虑版本影响总是好的。
  2. 兼容性破坏
    • 如果你要打破向后兼容(比如删除废弃的功能),就需要用版本号或者其他机制来区分不同版本,明确用户升级影响。
  3. 热替换需求
    • 如果用户希望在生产环境热替换库(不用重启程序就换库),就必须严格管理 ABI(应用二进制接口)兼容性。
    • 纯头文件库无法做到热替换。
  4. API和ABI变化的影响
    • 改动 API 需要小心,即使你能同时更新所有客户端,也要注意兼容性。
    • 如果有二进制兼容的需求,ABI 的变化更需要监控。
    • 必须及时通知用户所有变化及其影响。
  5. 版本管理的意义
    • 版本是维护者和用户沟通变化的桥梁。
  6. 合理预期
    • 有些用户会期待不合理的保证,比如符号地址、变量布局、类型细节等,这个演讲不涉及如何处理这些。
      总的来说,发布库要认真考虑版本和兼容性问题,尤其是在影响客户端代码和二进制接口时。

这段内容是在解释**API(应用程序编程接口)**的概念,尤其是从维护者和用户的角度来看,API 是一种“契约”,它包括:

什么是API?

  • API 是维护者和使用者之间的契约(合同)
  • API 包括两个部分:
    • 前置条件(Pre-conditions):调用方必须满足的条件或提供的东西
    • 后置条件(Post-conditions):被调用方在前置条件满足时保证提供的结果

用C++术语描述API:

  • 内部部分(Internal)
    • 名字(函数名、类名等)
    • 函数签名(参数类型、返回类型)
    • 声明的位置(头文件等)
  • 外部部分(External)
    • 前置条件(调用要求)
    • 后置条件(结果保证)
    • 其他保证(比如异常安全、线程安全等)

额外说明:

  • API 的很多内容不是语言本身能强制检查的,必须通过文档来说明。
  • 修改那些语言没覆盖的部分(比如文档中的前置条件、后置条件)需要格外谨慎,因为用户依赖这些保证。
    总结: API不仅仅是代码的声明和定义,更是维护者与使用者之间的协议,包含行为上的保证和约定。理解并尊重这个契约,对软件维护和演进非常重要。

这段内容讲的是API变更根据影响的分类和区别,具体可以总结如下:

API变更按影响分类

  1. 破坏性变更(API breaking change)
    • 需要客户端代码进行修改才能继续使用。
    • 比如删除函数、改变函数参数类型、修改行为导致不兼容。
  2. 非破坏性变更(API non-breaking change)
    • 向后兼容(backward compatible),老客户端代码还能正常用。
    • 但不一定向前兼容(forward compatible),新版本的客户端可能不兼容旧API。
  3. 无变更(No change to API)
    • 向后和向前兼容。
    • 完全保持兼容。

无影响的变更(Changes with no impact)

  • 不涉及添加、删除或改变契约(合同),也就是说API的行为和定义都没有变化
  • 只改实现细节,例如:
    • 修复bug
    • 性能优化
    • 代码重构(不改变外部行为)
  • 具体表现为:
    • 函数名字、签名都没改
    • 定义的行为和特定保证(比如算法复杂度、迭代器有效性)仍然保持一致。
      总结:
      理解API变更的分类对维护库和应用的兼容性非常关键。无论是对自己还是对使用者来说,清楚什么改动是破坏性的,什么是安全的,能更好地规划版本发布和代码迁移。

这部分内容详细列出了API变更中非破坏性和破坏性改动的具体例子,总结如下:

API非破坏性变更(API non-breaking changes)

  • 添加新合同(Add new contract)
    • 新函数
    • 新重载(overload)(*)
    • 新类型
    • 新命名空间
  • 放宽现有合同(Relax existing contract)
    • 函数或模板添加默认参数(*)
    • 结构体添加新成员
    • 放宽调用前条件(pre-conditions)
    • 收紧调用后条件(post-conditions)
    • 收紧已有的保证
    • 规范之前未定义的行为
      (*) 新的重载或默认参数可能影响调用决议,但一般算是兼容的。

API破坏性变更(API breaking changes)

  • 改变签名(Change signature)
    • 参数类型或顺序改变
    • 返回类型改变
  • 重命名(Renaming)
  • 移动声明到其他头文件(Moving declaration)
  • 收紧合同(Narrowing contract)
    • 收紧调用前条件(pre-conditions)
    • 放宽调用后条件(post-conditions)
    • 放宽已有的保证

特别强调

这种“收紧合同”是非常危险的,几乎总是破坏兼容性,强烈建议避免。

重点

  • 放宽合同一般是兼容的,但“收紧合同”是破坏性的。
  • 破坏兼容性的改动会导致依赖者必须修改代码。
  • 非破坏性变更是推荐的做法,尽量通过新增功能、默认参数等方式扩展API。

“contract”在软件工程和编程中,通常指的是**“契约”**,也就是双方达成的明确约定或者规则,保证代码按一定方式运行。这个概念来自“Design by Contract”(契约式设计),强调软件组件之间的接口和行为必须遵守一定的规则。

在API中的“contract”是什么意思?

简单来说,API的contract就是调用者和被调用者之间的约定,包括:

  • Pre-conditions(前置条件)
    调用函数前,调用者必须满足的条件。比如传入参数不能为null,数组大小必须大于0等。
  • Post-conditions(后置条件)
    函数执行完毕后,被调用者保证的条件。比如返回值一定非空,数据被正确修改等。
  • 不变性条件(Invariants)
    在函数调用前后保持不变的条件,保证数据结构或状态一致性。

举个例子

// 计算数组中第一个元素和第二个元素的和
int sumFirstTwoElements(int* arr, size_t size) {
    // contract - pre-condition:
    // size must be at least 2
    // arr must not be null
    if (!arr || size < 2) throw std::invalid_argument("Invalid input");
    // contract - post-condition:
    // returns the sum of arr[0] and arr[1]
    return arr[0] + arr[1];
}

这里的contract就是:

  • 调用者责任(前置条件): 传入的数组不能是空指针,大小至少为2。
  • 被调用者责任(后置条件): 返回两个元素的和。
    如果调用者不满足前置条件,函数可以抛异常或行为未定义。

为什么contract重要?

  • 明确接口双方责任,减少误用
  • 有助于编写更健壮和可维护的代码
  • 方便后续修改或优化代码,同时保证兼容性
  • 方便文档编写和自动化测试设计

这里讲的是**ABI(应用二进制接口)**的概念,以及它在C++中的组成和重要性。

关键点总结:

  • ABI是什么?
    ABI定义了二进制层面上程序组件如何相互“对话”。它决定了编译后的代码如何调用函数、处理异常、表示数据结构等。
  • ABI与API的区别
    • API是源代码层面的接口(函数、类的声明和行为契约)
    • ABI是二进制层面的接口(调用约定、符号名、内存布局)
  • ABI不在C++标准里,所以不同编译器或同一编译器不同版本之间ABI不一定兼容。

ABI的组成部分

基础设施(Infrastructure)

  • 调用约定(Calling convention)
  • 异常处理(Exception handling)
  • 名字修饰(Mangling)
  • C++运行时支持(Runtime)
    代码(Code)
  • 符号名称(Symbol names)
  • API类型的二进制表示(struct/class的内存布局)
  • 虚函数表(vtable)布局

举例说明

假如你用不同版本的编译器编译一个库和它的使用者,ABI不兼容就可能导致:

  • 函数调用失败
  • 传递的参数出错
  • 虚函数调用混乱
  • 数据结构内存布局不匹配
    这也是为什么发布二进制库时,ABI兼容性检查非常重要。

符号名(symbol names)虚表(vtable)布局在ABI中的关键作用:

符号名(Symbol Names)

  • 编译器会把函数名和签名(参数类型等)编码成唯一的符号ID,比如:
    void foo(int) -> _Z3fooi
    void foo(double) -> _Z3food
  • 这个过程叫名称修饰(name mangling),它让编译器能区分重载函数。
  • 一旦公开符号的ID改变,就会破坏ABI,因为调用者找不到正确的函数。
  • 公开符号包括:
    • 所有库对外暴露的API符号
    • 以及所有公有头文件中内联函数里使用的符号

实现变更示例

// 之前
namespace details {
  MY_EXPORT void bar();
}
inline void foo() {
  details::bar();
}
// 之后
namespace details {
  // bar()被移除
}
MY_EXPORT void bar(int);
inline void foo() {
  details::bar(0);
}
  • 改变了符号名,可能导致ABI破坏。

虚表布局(vtable Layout)

  • 虚表存储指向虚函数的指针
  • 编译器和平台通常有自己的规则来排列虚表项
  • 如果你修改了虚函数的顺序或者添加了新虚函数,虚表布局就会改变,ABI也会被破坏
    总结:改动公开符号的名称或签名,或改动虚表布局,都可能导致ABI不兼容。
    这就是为什么在发布库时,ABI兼容性管理非常重要。

**二进制表示(Binary representation)**在ABI兼容性中的关键影响:

二进制表示(Binary Representation)

  • 每个公开的结构体(struct)都有特定的内存布局,包含:
    • 结构体的总大小
    • 每个成员变量的大小
    • 每个成员在结构体中的起始偏移量
  • 实际布局受平台规则影响,例如对齐方式、填充字节等。

ABI 破坏的情况

  • 改变结构体成员的类型或顺序会破坏ABI
    因为访问成员时偏移发生变化,调用方和库方期望不同的内存结构。
  • 新增成员也会破坏ABI
    结构体大小和偏移都会改变,可能导致调用方错误读取数据。
  • 改变成员的访问权限(public/private/protected)也可能破坏ABI
    例如,inline 函数或模板代码访问成员权限变化可能影响符号解析。
    总结:结构体的二进制布局必须保持稳定,否则会导致二进制兼容性(ABI)破坏,调用方和库方的数据访问会不一致。

**C++库版本控制(Versioning)中,如何用语义化版本控制(Semantic Versioning, SemVer)**管理API和ABI的兼容性:

语义化版本控制 (SemVer)

  • 格式:X.Y.Z
    • X(Major)主版本号:表示破坏向后兼容的重大变更,升级需要用户修改代码。
    • Y(Minor)次版本号:表示增加新功能但不破坏现有功能,用户可安全升级。
    • Z(Patch)补丁版本号:只包含bug修复,不影响API兼容,安全升级。

如何纳入API版本控制

  • 遵循SemVer规范
  • 维护详细变更日志(changelog)
  • 清晰文档说明接口契约
  • 避免隐形的破坏性变更

关于ABI版本控制

  • 通常不单独版本控制ABI,因为:
    • 如果用户总是重新编译客户端代码(例如头文件库),ABI兼容性问题较少。
    • 头文件库(header-only)更不需要管理ABI版本。
    • 但需要在文档里明确说明ABI兼容性策略。
  • 如果需要,也可以把ABI变化纳入SemVer的主版本号部分(X号变动)。
    简言之:用SemVer管理API版本,ABI版本通常不单独管理,重点是做好文档和变更说明。

语义化版本控制(SemVer)在实际应用中的细节和注意点:

SemVer Reloaded(重新理解SemVer)

  • API或ABI破坏性变更 → 主版本号(major)增加
    重大变更,使用者必须修改代码,可能存在不兼容。
  • API或ABI非破坏性变更 → 次版本号(minor)增加
    添加功能或改进,兼容老版本。
  • 无变化 → 补丁版本号(patch)增加
    仅修复bug,不影响接口和兼容性。

依赖项变化的影响

  • 公共依赖(public dependency)主版本变化会破坏API
    这也可能导致ABI破坏,因为公共依赖接口变了。
  • 私有依赖(private dependency)主版本变化会破坏ABI
    虽然对外API没变,但内部实现变化导致二进制接口不同。

进一步做法

  • 提供迁移脚本或工具,比如基于Clang的自动重构工具,帮助用户快速适配新版接口。
  • 这样能促进用户更快升级,减少兼容性问题。

未来趋势

  • Contracts TS(合约拓展)
    有助于更容易检测和管理API变化。
  • Modules TS(模块拓展)
    可能改变二进制分发方式,甚至无需头文件。
    总结:语义化版本控制不仅要标记变化,还需关注依赖的版本兼容,配合迁移工具提升用户体验,并期待语言和标准库的新特性带来更好的版本管理能力。

Quiz 答案总结

  1. void foo(int);void foo(int, bool);
    • Breaking API change & Breaking ABI change
      新增参数,调用签名变了,二进制接口也变。
  2. int foo(int);int foo(long);
    • API change & Breaking ABI change
      参数类型变化,API和ABI都破坏。
  3. 结构体成员顺序变更
    struct A { int i; char *s; };
    struct A { char *s; int i; };
    
    • Breaking API change & Breaking ABI change
      结构体内存布局变,破坏兼容。
  4. 虚函数顺序变更
    struct A { void foo(); void bar(); };
    struct A { void bar(); void foo(); };
    
    • No change
      虚函数无virtual关键字无序号影响,顺序改变无影响。
  5. 函数实现逻辑变更
    int foo(int a, int b) { return a + b; }
    int foo(int a, int b) { return a > b ? a : b; }
    
    • Invisible breaking API change
      接口未变,但语义变了,用户代码可能受影响。
  6. 虚函数顺序变更
    struct A { virtual void foo(); virtual void bar(); };
    struct A { virtual void bar(); virtual void foo(); };
    
    • Breaking ABI change
      虚表布局变,破坏ABI。
  7. 结构体新增成员(中间插入)
    struct A { int i; bool b; char *s; };
    struct A { int i; bool b; char t[2]; char *s; };
    
    • ABI change
      结构体大小和布局变,破坏ABI。
  8. 函数默认参数新增
    void foo(int);
    void foo(int, bool = false);
    
    • API change & Breaking ABI change
      默认参数编译期处理,调用签名在二进制层面变。
  9. 函数名变更
    void foo(int);
    void bar(int);
    
    • Breaking API & ABI change
      名称改变,符号名变化。
  10. 结构体成员名改变(类型不变)
    struct A { int i; char *s; };
    struct A { int i; char *str; };
    
    • Breaking API change
      成员名是API契约的一部分,改变名字破坏API。
  11. inline函数内调用的内部函数名变
    namespace details {
      int bar(int);
    }
    inline int foo(int x) { return details::bar(x); }
    namespace details {
      int bazz(int);
    }
    inline int foo(int x) { return details::bazz(x); }
    
    • Breaking ABI change
      inline函数里调用的符号变化,导致ABI破坏。
      总结:系统成功关键是不破坏向后兼容,特别是重大版本发布前必须提前通知用户。

你可能感兴趣的:(CppCon,学习,c++,开发语言)