我强烈推荐的一件事是为您的整个库使用一个版本号。我在看到以前没有这样做的代码库中遭受了如此多的痛苦之后才这么说。
在之前的代码库中,它是一个插件架构。插件将被传递一个函数指针,如下所示:
EXPORT void my_plugin_func(LookupFunction* lookup)
{
// Retrieve version 3 of the drawing interface.
struct DrawingInterface3* drawer = lookup(DRAWING_INTERFACE, 3);
// Retrieve version 1 of the widget interface.
struct WidgetInterface7* widget = lookup(WIDGET_INTERFACE, 1);
// Retrieve the latest version of the brush interface.
struct BrushInterface* brush = lookup(BRUSH_INTERFACE, BRUSH_INTERFACE_VER);
...
}
虽然考虑到您可以在每个接口级别上混合、匹配和使用您想要的任何可用接口版本,这看起来很酷,但您可能会开始想象这是一场维护噩梦。
首先,因为要处理的版本号太多(每个可用的接口都有一个),所以开发人员有很多方法会出错。考虑到在一个大团队中出错的方式有很多,事情偶尔会出错。我们让开发人员在同一个周期内两次或多次修改版本号,或者更糟糕的是,忘记为他们彻底修改的界面增加版本号,这种情况并不少见。
如果 SDK 附带此错误,则后一个错误将是灾难性的,因为它会使曾经为 DrawingInterface3 编写的所有插件损坏,因为它们不再是二进制兼容的。同时,所有现在针对DrawingInterface3(实际上应该是DrawingInterface4)编写插件的人(但是更新界面的人忘记将其版本化)现在需要针对固定的SDK重新编译他们的所有插件,而不是他们所有人都会这样做(有些人会发布一个插件并停止维护它)。因此,即使我们解决了实际上针对 DrawingInterface3 编写的插件的问题,它也会永久地使所有插件都针对应该是 DrawingInterface4 foobar 的内容构建。
但这还不是最严重的问题。最糟糕的是,现在引擎盖后面的代码必须处理接口的爆炸性组合。有人可能想使用绘图接口版本 2 绘制到 Widget 版本 7,这可能需要一个完全独立的代码分支,从那些使用绘图接口 5 绘图到 Widget 版本 3。尽管在设计多态解决方案下有一种聪明的方法引擎盖,导致需要最疯狂的爆炸性代码来改变任何界面版本,而这样做几乎没有任何实际好处。
因此,最重要的是,我建议将其保留为整个库/SDK 的单一版本号。你可以这样做:
// Tell the system what SDK version we're using.
EXPORT int32_t my_plugin_version(void)
{
return SDK_VERSION_NUMBER;
}
// The system will now know what interfaces to provide
// to this plugin after calling the above function.
EXPORT void my_plugin_func(LookupFunction* lookup)
{
// Retrieve latest version of the drawing interface.
struct DrawingInterface* drawer = lookup(DRAWING_INTERFACE);
// Retrieve latest version of the widget interface.
struct WidgetInterface* widget = lookup(WIDGET_INTERFACE);
// Retrieve latest version of the brush interface.
struct BrushInterface* brush = lookup(BRUSH_INTERFACE);
...
}
使用面向对象的语言,任务更容易/微不足道(取决于
关于语言)但对于 C?
对我来说,让事情变得微不足道的并不是真正的 OOP。实际上,使用本机代码的 OOP 可以使事情变得更加困难。例如,对 dylib 进行版本控制甚至确保使用 C++ 的广泛兼容性可能是一场噩梦,需要前面提到 C++ 的许多特性(异常处理、虚函数、标准库、一般对象,如果您不仅要针对其他 C++ 编译器,而且还有其他语言的 FFI 等)。容易与否的事情与代码的动态链接方式有关。对于使用 JIT 或解释器的语言,这要简单得多。
对于本机代码,这往往要复杂得多,因为它引入了各种 ABI 问题,例如调用约定,因为您的库必须以二进制形式直接使用,而不是动态编译和链接对于用户的特定机器、标准库等。使用非本机代码,这有点像你在开源你的库(实际上没有这样做,而是运送一些不完全是本机的中间代码,比如 IR 字节码是即时编译的)。当您实际上不向用户发送本机二进制文件时,“开源”自然会使事情变得简单得多。
我之所以使用 C API 而不是 C++,主要是因为 C API 比 C++ 更简单、更普遍兼容。我使用 C++ 来实现所有的 C API,但是在将 C 严格用于 API 接口本身(与整个 SDK 的单个版本号结合使用)之后,我的生活变得简单了很多。
方便和安全
我在 cmets 中发现了这一点,但我有一些建议:不要试图让您的 API(实际上是动态链接的)非常方便和安全地使用。否则,除非您的库相当琐碎(而且相当琐碎的库通常不会过多关注与版本控制相关的架构问题),否则您可以成倍增加版本控制维护工作。
相反,如果您希望使用您的库的人们拥有非常漂亮和方便且安全使用的界面,请在您导出的 API 之上为他们提供一个带有包装器的静态库。就向后二进制兼容性而言,这个静态链接库不是您必须维护的东西,因为它们实际上是由您的库的用户构建的。这个静态“便利/帮助”库可以随心所欲。
我建议这样做的原因是,如果您过于努力地使导出的原始 API 真正方便使用,您可能会遇到现在维护 10 倍于遗留代码的情况。这就像重构减去所有好处,因为现在您必须维护“不太方便”的功能实现的旧版本、适度“方便/安全”功能的新版本、最新版本等。你必须维护整个遗留代码,因为您不断引入所有新功能和更改,同时弃用一些东西只是为了让您导出的 API 越来越方便和安全地使用。
所以我不建议这样做,而是建议专注于静态库中的那种东西。为了方便/安全,不要导出更多功能。仅导出新功能,因为它们提供了以前没有的所需功能。专注于其他地方的帮手/便利的东西。当然,您可能仍然需要在某种程度上保持便利库的“源代码兼容性”,但是如果他们必须每隔几年更改一些代码以针对您的最新版本构建东西,那么针对您的库编写代码的人可能会原谅您图书馆。二进制兼容性是不同的,因为您可能会发现,从现在起 10 年后,您仍然无法删除那 10 年的旧代码,因为用户仍然发现使用旧版本构建的旧二进制文件仍然有用。因此,没有太多遗留代码确实很有帮助,如果您不尝试使导出的函数(“原始函数”,未包装)尽可能方便,那么您往往会拥有更少的代码。由于与包装器的源代码兼容性,只要您保持与导出 API 的旧版本的二进制兼容性,无论内部发生什么变化,它们都不可能破坏,因此它有助于保持最小的目标以保持二进制兼容性。
除此之外,试图使您的 C API 方便/安全通常是徒劳的,因为例如,您在 C API 之上施加的任何安全性都不会使实际上需要 RAII 一致性的 C++ 开发人员无需在您的库之上编写自己的包装器,异常处理就永远快乐。 C# 开发人员永远不会想要以原始形式使用该库——他们会比 C++ 开发人员更加极端。
不管怎样,人们经常会在你的库之上编写安全的包装器。如果您想要使用一个安全且漂亮的库,如果它的规模不小(例如跨越数百个标头),那么对我来说最有效的途径就是只专注于导出必需 功能,没有便利/帮助的东西,并在单独的静态链接库中构建便利/帮助的东西,您直接将其源代码交给用户来构建。
printf
我喜欢 printf 的例子,因为如果您查看 printf,它是一个可变参数函数,在 C 中使用这些函数非常不安全,并且通常是开发人员的绊脚石。但另一方面,它是一个古老的函数,已经存在了几十年,并且在今天仍然适用,不需要printf_ver2、printf_ver3 等等,这是因为可变参数该功能的性质允许它在不引入新功能的情况下进行扩展。
所以我经常看到那里的甜蜜点有 printf 这样的东西,这将允许您在未来的版本中扩展它,而无需引入大量功能和遗留代码来维护,但同时在顶部提供包装器,它们是使用安全(因此类比的printf 只能在实现此类包装器的一个地方使用,而不是由用户直接使用)。然后,该组合应该为您提供一个小目标来维护向后兼容性,同时提供更安全、更方便使用的东西。对我来说,通过版本控制来优先考虑可维护性和可扩展性,然后分别为用户解决便利性和安全性等问题,这对维护长期存在的库有很大帮助,因为长期存在的库的维护工作可能会在成本上成为天文数字从长远来看,如果您不小心保持二进制导出的目标尽可能小且尽可能简约。维护一大堆 20 年前的代码肯定不好玩,因为有些人仍在使用针对它编写的东西。
简易扩展
最后要注意的是,在 C 语言中,您可以添加一些东西而不会影响二进制兼容性和版本控制接口。例如,您可以在struct 的底部添加字段而不影响二进制兼容性,前提是struct 的用户不需要知道它的大小(例如:他们没有自己实例化它)。在这种情况下,可以向那些使用旧版本库的人提供指向最新 struct 实例的指针,但他们根本不会看到您添加的新字段,因为他们看不到 struct 的最新定义(没有最新的标题,即),但他们看到的所有内容仍然可以正常工作,并且与以前完全相同(前提是您在添加新字段时没有更改现有函数的实现)。因此,只要以保留 ABI 的方式正确完成,而不需要您更改库版本并且必须实现全新的接口,那么就有很大的添加空间而无需维护多个版本的东西。
我建议尽可能多地利用它,因为这是我在以前的代码库中看到的另一件事。一些开发人员针对根本不影响 ABI 并且不影响旧版本库用户的功能的更改而更改 SDK 版本,并且不必要地创建了全新的代码分支来维护。再次,所需的维护工作使您添加要维护的版本数倍增,因此它有助于利用并找到尽可能多的方法来避免版本化问题,并使您必须为每个版本维护的代码量尽可能地保持在最低限度。
ABI 也有点棘手,因此对每个旧版本的接口进行单元测试非常有帮助,以确保它们在引入新版本时仍然可以正常工作。您甚至不必一遍又一遍地为旧版本构建单元测试,因为这样做的目的是确保二进制兼容性。因此,您可以只归档它们的可执行文件并在 CI 中运行它们,例如,不必一遍又一遍地构建源代码(实际上有参数反对一遍又一遍地构建它们,因为重点是确保针对旧版本构建的旧二进制文件仍然适用于库的最新二进制文件)。当您浏览 ABI 的地雷和向后兼容性时,这些单元测试也将消除任何疑虑,即您所做的更改是否会影响以前的二进制文件,以及您是否需要对全新的接口和实现进行版本化或可以只需修改现有的。