SDK开发注意事项

在开发一个软件项目时,为了尽可能复用已有组件,降低后续维护的成本,常常需要进行模块化设计——将一部分较为独立的功能抽象出来,封装成 sdk 供调用方使用。 如果你是一名 C++ 开发者,在为调用方开发 C++ 版本的 sdk 时,需要注意哪些问题呢?本文给你几个小的 tips (从而减少被 sdk 调用方打的概率)。

一、确定 sdk 的使用形式

一般来说,c++ sdk 有 2 种使用形式:

header-only 型
header-files + library
header-only 型的 c++ sdk ,特点是提供给调用方的整个 sdk 中,只包括头文件(.h 结尾),无任何其他文件类型。这种形式的 sdk ,目录结构是这样的:

± /include
| ± a.h
| ± b.h
| ± c.h
| ± …
这种类型的 sdk 在使用时非常简单:调用方只需要 include 相应的头文件,即可使用 sdk 的功能,所谓“开箱即用”。

但是 header-only 型的 sdk 也有 2 个主要的弊端:一是这种类型的 sdk ,不能依赖其它的第三方库(除非依赖的其他第三方库也都是 header-only 型);二是如果 sdk 本身体积较大的话,由于 include 的实际操作是预编译阶段进行头文件代码的复制粘贴,所以当 sdk 本身体积较大时,编译时间会很长,编译出来的文件体积也会很大。因此,header-only 型 sdk 的主要应用范围是无其它第三方依赖的小规模库,例如 spdlog 这种代码量不大的日志库。

应用范围更加广泛的是第二种形式,即提供的 sdk 中包括了头文件(.h 结尾)+ 库文件。库文件可以分为静态库文件(windows 下以 .lib 结尾、linux 下以 .a 结尾)和动态库文件(windows 下以 .dll 结尾、linux 下以 .so 结尾)。关于两者的具体区别,这里就不做过多介绍了,知乎上可以搜到很多相关的文章和回答。

在这种形式下,提供的 sdk 的目录结构大概是这样的(以提供的是动态库 .so 为例):

± /include
| ± a.h
| ± b.h
| ± c.h
± /lib
| ± libxxx.so
调用方在使用这种形式的 sdk 时,相比第一种 header-only 方式,稍微复杂了些:除了要 include 必要的头文件以外,还要指定链接的库文件。

有时,提供的 sdk 中还依赖了其它第三方动态库。如果调用方的机器上缺少这些库的话,就会造成程序运行时出错。为了防止这种情况发生,可以顺便将 sdk 中依赖的第三方动态库也打包到 sdk 中。因此,第二种形式的 sdk ,最终结构是这样的:

± /include
| ± a.h
| ± b.h
| ± c.h
± /lib
| ± libxxx.so
± /dependencies
| ± libaaa.so
| ± libbbb.so
| ± libccc.so
此时终于构成了一个完整的 sdk ,可以供调用方使用了。

二、减少暴露出来的接口信息

想象一下,如果你是 sdk 的调用方,你会希望如何去使用这个 sdk 呢?你是不是希望自己可以“开箱即用”、“傻瓜式”操作?无论是 header-only 形式,还是 header-files + library 形式的 sdk ,都要尽可能减少暴露出来的头文件数量。为了达到这个目标,可以使用下面几个小技巧。

1.只暴露必须用到的头文件

一般来说,必须暴露出来的接口类,包含以下 3 种情况:

作为“驱动”类,驱动 sdk 中的流程
作为输入参数
作为输出参数
所谓“驱动”类,举个例子,假如你的 sdk 的功能是获取天气信息,那么很可能需要下面这样一个 WeatherGetter 类作为整个 sdk 的入口:

class WeatherGetter {
public:
// 初始化 bool init();

// 获取天气信息     void getWeatherInformation(const GeoInfo& geo_info, WeatherInfo& weather_info);

// 销毁     void destroy();

private:
// … };
这样的“驱动”类必不可少。因此在开发 sdk 时,我们需要做好抽象,减少暴露出来的“驱动”类数量。

而上面例子中的 GeoInfo 和 WeatherInfo ,分别是作为输入参数和输出参数的接口类。当输入参数和输出参数数量不多的时候,可以考虑使用 c++ 的基本类型 bool 、int 等作为参数;而当参数数量较多时,可以考虑通过结构体或者类的方式进行封装。

2.多个头文件整合为一个头文件

还是看上面的例子,如果 WeatherGetter 、 GeoInfo 和 WeatherInfo 分属于 3 个不同的头文件,那么在调用方使用的时候,需要显式 include 3 次;如果暴露出来的头文件数量更多的话,那么调用方就需要手动 include 更多次,这对调用方而言是非常不友好的。

一种优化调用方用户体验的措施是使用一个头文件,include 整个项目暴露出来的所有其它头文件。这样在调用方使用的时候,就只需要 include 这一个头文件即可。比如上面的获取天气信息的 sdk ,可以写一个 weather.h :

// weather.h
#include “weather_getter.h” #include “geo_info.h” #include “weather_info.h”
上文已经提到过,由于 include 的原理其实是在预编译期把被 include 的文件复制粘贴过来,所以 sdk 的调用方在使用时,直接 include “weather.h” 就可以了。

但是,这种方法其实是一把“双刃剑”:一方面,它简化了调用方的操作;但与此同时,由于 include 的原理其实是复制粘贴,所以当头文件很多、长度很长时,多余的头文件引用会增加编译时间,所以我们要进行权衡。

3.简化接口类,隐藏具体的实现类

有时,你可能遇到这种情况:接口类(例如上面的 WeatherGetter 类)虽然提供的 public 方法不多,但是却有很多个 private 方法或字段声明在头文件中:

class WeatherGetter {
public:
// public 方法只有 3 个 bool init();
void getWeatherInformation(const GeoInfo& geo_info, WeatherInfo& weather_info);
void destroy();
private:
// private 方法却有 1000 个。。。 // … // 再加上 100 个 private 的字段 // … };
出现这种情况,说明我们在编写 sdk 时,类的拆分工作做得不够好,造成了单个类的体积过大。如果我们不希望让调用方看到如此多的 private 方法和字段,应该怎么办呢?对于这种情况,其实也有一种解决办法,就是把这种头文件声明很庞大的类,作为具体的实现类隐藏起来,然后在外面套上一个接口类暴露给调用方。

例如把上面的 WeatherGetter 类换个名字,叫作 WeatherGetterImpl 类,放在 weather_getter_impl.h 头文件中,其它保持不变:

// weather_getter_impl.h
class WeatherGetterImpl {
public:
// public 方法只有 3 个 bool init();
void getWeatherInformation(const GeoInfo& geo_info, WeatherInfo& weather_info);
void destroy();
private:
// private 方法却有 1000 个。。。 // … // 再加上 100 个 private 的字段 // … };
然后新建一个 weather_getter.h 文件,内容如下:

// weather_getter.h
class WeatherGetter {
public:
WeatherGetter() {
m_weather_getter = new WeatherGetterImpl();
}

~WeatherGetter() {
    delete m_weather_getter;
}

// public 方法依然只有 3 个,但是省去了 private 中的众多方法和字段,     bool init() {
    return m_weather_getter->init();
}

void getWeatherInformation(const GeoInfo& geo_info, WeatherInfo& weather_info) {
    return m_weather_getter->getWeatherInformation();
}

void destroy() {
    return m_weather_getter->destroy();
}

private:
// 此时只有一个 private 字段 WeatherGetterImpl* m_weather_getter;
};
这样,作为暴露出来的接口, WeatherGetter 在形式上很简单,而作为具体实现的 WeatherGetterImpl 类被隐藏了起来。当然,我们在开发时,还是要做好类的拆分工作,避免出现一个体积巨大的类,难以进行后续的维护。

三、使用前置声明

前面说过,include 的基本原理是在预编译阶段将被包含的头文件进行复制粘贴。因此,为了缩短编译时间,我们在编写头文件的时候,应该遵循的原则是让头文件只引用必要的其它头文件。但有时,在一个头文件中会使用其它头文件中的类或方法,这个时候就“不得不”引用其它的头文件。

而事实上,有一个小技巧可以让你摆脱这种“不得不”的情况,那就是使用前置声明。比如下面的例子:

// weather_getter.h
class GeoInfo;
class WeatherInfo;

class WeatherGetter {
public:
void getWeatherInformation(const GeoInfo &geo_info, WeatherInfo &weather_info);
};
class GeoInfo; 和 class WeatherInfo; 就属于前置声明,这样在 weather_getter.h 中就无需 include GeoInfo 和 WeatherInfo 所在的头文件了。

当前置声明的类数量较多时,可以使用一个 xxx_fwd.h 文件进行统一前置声明,这样其它类在使用前置声明时,只需要把这个 xxx*_*fwd.h 文件引用进来就可以了,无需一个个手动进行前置声明。

// weather_fwd.h
namespace weather {
class WeatherGetter;
class GeoInfo;
class WeatherInfo;
}
通过上面的分析,可以看到使用前置声明是一种有效缩短编译时间的方法。但是要注意的是,前置声明只是一个声明,属于不完整的类型。也就是说,如果在头文件中用到的只是指针、引用、智能指针等不完整使用方式,那么可以使用前置声明;但是,如果头文件中用到的是值类型、继承、对象的方法等完整使用方式,由于前置声明本身只是一个声明占位,没有给出定义,因此编译器无法获知这个类的构造函数、方法等,所以在这种情况下,只能老老实实地引用对应的头文件,否则会编译报错。另外,在源文件(.cpp 结尾)中,一般要真正使用某个类的方法,所以也要去引用具体的头文件。

四、提供 demo 程序和使用文档
使用了前面所有的技巧以后,最终提供给调用方使用的 sdk ,它的结构是这样的:(header-files + library 形式)

± /include
| ± weather_getter.h
| ± geo_info.h
| ± weather_info.h
| ± weather.h
| ± weather_fwd.h
± /lib
| ± libweather.so
± /dependencies
| ± libaaa.so
| ± libbbb.so
| ± …
此时调用方在使用的时候,只需要引用一个头文件 weather.h ,然后链接 libweather.so 库即可,使用方式现在变得非常简洁。

为了让调用方在使用 sdk 时更加清晰,一般我们要提供 demo 程序和使用文档。一个好的项目,往往都提供了详尽的文档、丰富的示例程序和教科书式的 README.md。所以,我们在开发 sdk 的时候,写好核心功能的代码只是其中的一步,简洁的结构、“傻瓜化”的使用方式、完整的文档都是需要我们注意的地方。

五、总结

回顾一下,本文都说了哪些内容呢?

首先是确定好 sdk 的形式,到底是 header-only 型,还是 header-files + library 型;
然后是尽可能减少暴露出来的接口信息,本文中介绍了几种方法,包括只暴露必须头文件、合并多个头文件、简化接口类与隐藏实现类;
接下来是使用前置声明,以缩短编译时间;
最后是提供尽可能友好的 demo 程序和说明文档。
在平时大家开发 C++ 项目的时候,无论是个人开发的小项目,还是需要团队合作的大中型项目;无论是开源项目,还是公司内部项目,都可以认真考虑一下上述的几点建议,开发出对其他人使用友好、同时也对自己后续维护友好的 sdk。

猜你喜欢

转载自blog.csdn.net/amwha/article/details/115065147