C ++ 中不容忽视的 25 个 API 错误设计!

对于许多C ++开发人员来说,API设计可能会在其优先级列表中排名第3或第4。大多数开发人员都倾向于使用C ++来获得原始功能和控制权。因此,性能和优化的想法占据这些开发者的时间的百分之八十。

当然,每个C ++开发人员都会考虑头文件设计的各个方面,但是API设计不仅仅是头文件设计那样。事实上,我强烈建议每一个开发人员在其API的设计上,无论是面向公共还是面向内部,都给予一些帮助,因为这样可以节省你大量的维护成本,提供平滑的升级路径,并为你的客户节省麻烦。

下面列出的许多错误都是我自己的经验和我从Martin Reddy的精彩书籍《C ++ API Design》(我强烈推荐的书)中学到的东西的结合。如果你真的想要深入了解C ++ API设计,那么你应该阅读Martin Reddy的书,然后使用下面的列表作为更多的清单来强制执行代码审查。

错误#1:不将你的API放在命名空间中

为什么这是一个错误?

因为你不知道将使用哪个代码库,特别是对于外部API。如果不将API功能限制在命名空间中,则可能导致与该系统中使用的其他API发生名称冲突。

例如:

让我们考虑一个非常简单的API和使用它的客户端类:

//API - In Location.h
class vector
{
public:
  vector(double x, double y, double z);
private:
  double xCoordinate;
  double yCoordinate;
  double zCoordinate;
};
//Client Program
#include "stdafx.h"
#include "Location.h"
#include <vector>
using namespace std;
int main()
{
  vector<int> myVector;
  myVector.push_back(99);
  return 0;
}

如果有人试图在同时使用std::vector的项目中使用这个类,他们会得到一个错误“error C2872: ‘vector’: ambiguous symbol”。这是因为编译器无法决定客户端代码引用的向量是std::vector还是location.h中定义的vector对象。

如何解决这个问题?

始终将API放在自定义命名空间中,例如:

//API
namespace LocationAPI
{
  class vector
  {
  public:
    vector(double x, double y, double z);
  private:
    double xCoordinate;
    double yCoordinate;
    double zCoordinate;
  };
}

另一种方法是为所有公共API符号添加一个唯一的前缀。如果遵循此约定,我们将调用我们的类“lvector”而不是“vector”。此方法用于OpenGL和QT。

在我看来,如果你正在开发纯C的API,这是有道理的。确保所有公共符号符合此唯一命名约定是另一个令人头痛的问题。如果你正在使用C ++,那么你应该只在命名空间中对API功能进行分组,让编译器为你完成繁重的任务。

我还强烈建议你使用嵌套命名空间来进行功能分组或将公共API与内部API分开。一个很好的例子是Boost库,它们可以自由地使用嵌套的命名空间。例如,在根“boost”命名空间内,boost :: variant包含Boost Variant API的公共符号,boost :: detail :: variant包含该API的内部详细信息。

误#2:在你的公共API头的全局范围中包含“using namespace”

为什么这是一个错误?

这将导致被引用命名空间中的所有符号在全局命名空间中变得可见,并首先抵消掉使用命名空间的好处。

另外:

  1. 头文件的使用者不可能撤消命名空间包含,因此他们被迫使用决策来使用你的命名空间,这是不可取的。
  2. 它极大地增加了命名空间首先要解决的冲突的可能性。
  3. 当引入新版本的库时,程序的工作版本可能无法编译。如果新版本引入的名称与应用程序正在从另一个库使用的名称冲突,则会发生这种情况。
  4. 代码中的“using namespace”部分从包含头部的代码中出现的那一点开始生效,这意味着在此之前出现的任何代码都可能与该点之后出现的任何代码区别对待。

如何解决这个问题?

1.尽量避免在头文件中放置任何使用的命名空间声明。如果你需要一些名称空间对象来编头文件,请在头文件中使用完全限定名称(例如std :: cout,std :: string)。

//File:MyHeader.h:
class MyClass
{   
private:
    Microsoft::WRL::ComPtr _parent;
    Microsoft::WRL::ComPtr _child;
}

2.如果上面的建议#1导致代码混乱太多 - 将“using namespace”用法限制在头文件中定义的类或命名空间内。另一个选择是在头文件中使用范围别名,如下所示。

//File:MyHeader.h:
class MyClass
{
namespace wrl = Microsoft::WRL; // note the aliasing here !
private:
    wrl::ComPtr _parent;
    wrl::ComPtr _child;
}

有关与C ++头文件相关的其他问题,请参阅帖子“十大C ++头文件错误以及如何修复它们”(https://www.acodersjourney.com/top-10-c-header-file-mistakes-and-how-to-fix-them/)。

错误#3:无视“三法则”

什么是“三法则”?

三法则是,如果一个类定义了析构函数、复制构造函数或复制赋值运算符,那么它应该明确定义三个函数所有,而不是依赖它们的默认实现。

为什么忽略三法则是一个错误?

如果你定义它们中的任何一个,很可能你的类正在管理一个资源(内存,fileHandle,套接字等)。从而:

  • 如果你编写/禁用复制构造函数或复制赋值运算符,您可能需要对另一个执行相同操作:如果执行“special”工作,则另一个可能也应如此,因为这两个函数应该具有相同的效果。
  • 如果你明确地编写了复制函数,则可能需要编写析构函数:如果复制构造函数中的“special”工作是分配或复制某些资源(例如,内存,文件,套接字等),则需要在其中释放它析构函数。
  • 如果你明确地编写了析构函数,则可能需要显式写入或禁用复制:如果必须编写一个非常重要的析构函数,通常是因为你需要手动释放该对象所持有的资源。如果是这样,那些资源可能需要仔细复制,然后你需要注意对象的复制和分配方式,或者完全禁用复制。

让我们看一个例子,在下面的API中,我们有一个由MyArray类管理的资源int *。我们为类创建了一个析构函数,因为我们知道在销毁管理类时我们必须为int *释放内存。到现在为止还挺好。

现在让我们假设你的API的客户端使用它如下所示。

int main()
{
  int vals[4] = { 1, 2, 3, 4 };
  MyArray a1(4, vals); // Object on stack - will call destructor once out of scope
  MyArray a2(a1); // DANGER !!! - We're copyin the reference to the same object
  return 0;
}

那么这里发生了什么?

客户端通过构造函数在eth堆栈上创建了类a1的实例。然后他通过从a1复制创建了另一个实例a2。当a1超出范围时,析构函数将删除底层int *的内存。但是当a2超出范围时,它会再次调用析构函数并尝试再次为int *释放内存(此问题称为双重释放),这会导致堆损坏。

由于我们没有提供复制构造函数并且没有将我们的API标记为不可复制,因此客户端无法知道他不应该复制MyArray对象。

如何解决这个问题?

我们可以这样一些事情:

  1. 为创建底层资源的深层副本的类提供复制构造函数,例如(int *)就是这种情况。
  2. 通过删除复制构造函数和复制赋值运算符使类不可复制。
  3. 最后,在API头文件中提供该信息。

这是通过提供复制构造函数和复制赋值运算符来解决问题的代码:

// File: RuleOfThree.h
class MyArray
{
private:
  int size;
  int* vals;
public:
  ~MyArray();
  MyArray(int s, int* v);
  MyArray(const MyArray& a); // Copy Constructor
  MyArray& operator=(const MyArray& a); // Copy assignment operator
};
// Copy constructor
MyArray::MyArray(const MyArray &v)
{
  size = v.size;
  vals = new int[v.size];
  std::copy(v.vals, v.vals + size, checked_array_iterator<int*>(vals, size));
}
// Copy Assignment operator
MyArray& MyArray::operator =(const MyArray &v)
{
  if (&v != this)
  {
    size = v.size;
    vals = new int[v.size];
    std::copy(v.vals, v.vals + size, checked_array_iterator<int*>(vals, size));
  }
  return *this;
}

解决此问题的第二种方法是通过删除复制构造函数和复制分配运算符使类不可复制。

// File: RuleOfThree.h
class MyArray
{
private:
  int size;
  int* vals;
public:
  ~MyArray();
  MyArray(int s, int* v);
  MyArray(const MyArray& a) = delete;
  MyArray& operator=(const MyArray& a) = delete;
};

此时,当客户端尝试复制类时,他将遇到编译错误:error C2280: ‘MyArray::MyArray(const MyArray &)’: attempting to reference a deleted function

C++ 11的附录:

“三”法则现在已转换为“五”法则,用于移动构造函数和移动赋值运算符中的因子。因此,在我们的例子中,如果要使类不可复制和不可移动,我们将标记移动构造函数和movbe赋值操作符为已删除。

class MyArray
{
private:
  int size;
  int* vals;
public:
  ~MyArray();
  MyArray(int s, int* v);
  //The class is Non-Copyable
  MyArray(const MyArray& a) = delete;
  MyArray& operator=(const MyArray& a) = delete;
  // The class is non-movable
  MyArray(MyArray&& a) = delete;
  MyArray& operator=(MyArray&& a) = delete;
};

附加警告:如果为类定义了复制构造函数(包括将其标记为已删除),则不会为该类创建移动构造函数。因此,如果你的类只包含简单的数据类型,并且你计划使用隐式生成的移动构造函数,那么如果你定义复制构造函数则不可能。在这种情况下时,你必须显式定义移动构造函数。

错误#4:不将API中的移动构造函数和移动赋值运算符标记为noexcept

一般来说,预计不会抛出移动操作。你基本上是从源对象中窃取了一堆指针并将它组合到你的目标对象,理论上它不应该抛出。

为什么这是一个错误?

如果该构造函数不破坏其强大的异常安全保证,则STL容器只能在其调整大小操作中使用移动构造函数。例如,std :: vector不会使用你的API对象的移动构造函数,如果它可以抛出异常。这是因为,如果在移动中引发异常,则正在处理的数据可能会丢失,而在复制构造函数中,原始数据不会更改。

因此,如果你没有在API中将MOVE CONSTRUCTOR和MOVE ASSIGNMENT OPERATOR标记为noexcept,则如果客户计划使用STL容器,则可能会对你的客户产生严重的性能影响。本文显示,与可移动的类相比,无法移动的类花费大约两倍的时间放置在向量中并遇到不可预测的内存峰值。

怎么解决?

只需将移动构造函数和移动赋值运算符标记为“noexcept”

class Tool
{
public:
  Tool(Tool &&) noexcept;
};

错误#5:不将不可抛出的API标记为noexcept

为什么这是API设计错误?

将API标记为noexcept有多种分歧,包括某些编译器优化,例如移动构造函数的优化。但是,从API设计的角度来看,如果你的API真的没有抛出,它会降低客户端的代码复杂性,因为现在他们不需要在代码中有多个try / catch块。此外,这样做还有两个额外的好处:

  1. 客户端不需要为这些异常代码路径编写单元测试
  2. 由于代码复杂性降低,客户端软件的代码覆盖率可能会更高。

怎么解决?

只需标记不作为noexcept抛出的API。

错误#6:不将单个参数构造函数标记为显式

为什么这是一个API设计错误?

允许编译器进行一次隐式转换以将参数解析为函数。这意味着编译器可以使用可用* single argument*调用的构造函数将一种类型转换为另一种类型,以获得正确的参数类型。

例如,如果我们在location API中有以下单个参数构造函数:

namespace LocationAPI
{
  class vector
  {
  public:
    vector(double x);
    // .....
  };
}

我们可以调用以下代码:

LocationAPI::vector myVect = 21.0;

这将使用double参数21.0调用单参数vector构造函数。但是,这种类型的隐式行为可能令人困惑、不直观,而且在大多数情况下,可能是无意的。

作为这种不需要的隐式转换的进一步示例,请考虑以下函数签名:

void CheckXCoordinate(const LocationAPI::vector &coord, double xCoord);

在不将LocationAPI :: vector的单参数构造函数声明为显式的情况下,我们可以将此函数的调用如下所示:

CheckXCoordinate(20.0, 20.0);

当然这会削弱API的类型安全性,因为现在编译器不会将第一个参数的类型强制为显式向量对象。

结果,客户端有可能忘记正确的参数顺序并以错误的顺序传递它们。

如何解决这个问题?

这就是为什么除非你知道要支持隐式转换,否则应始终对任何单参数构造函数使用explicit关键字。

class vector
{
public:
  explicit vector(double x);
  //.....
}

错误#7:不将只读数据/方法标记为const

为什么这是一个错误?

有时,你的API会将来自客户端的一些数据结构作为输入。将方法和方法参数标记为const表示客户端将以只读模式使用该数据。相反,如果你没有将API方法和参数标记为const,那么你的客户可能倾向于向你传递数据副本,因为你没有做出此类保证。根据客户端代码调用API的频率,性能影响的结果可以从轻微到严重。

如何解决这个问题?

当你的API需要对客户端数据进行只读访问时,请将API方法和/或参数标记为const。

假设你需要一个函数来只检查两个坐标是否相同。

//Don't do this:
bool AreCoordinatesSame(vector& vect1, vector& vect2);

相反,将方法标记为const,以便客户端知道你不会修改客户端传入的矢量对象。

bool AreCoordinatesSame(vector& vect1, vector& vect2) const;

Const正确性是一个很大的话题 - 请参考一本好的C ++教科书或阅读https://isocpp.org/wiki/faq/const-correctness中的FAQ部分。

错误#8:通过const引用返回API的内部

为什么这是一个错误?

从表面上看,通过const引用返回一个对象似乎是双赢的。这是因为:

  1. 避免不必要的复制。
  2. 客户端无法修改数据,因为它是const引用

但是,这可能会导致一些棘手的问题 ——即:

  1. 如果客户端API在内部解除分配后保留并使用引用,该怎么办?
  2. 什么是客户端使用const转换来抛弃对象的常量并修改它?

如何解决这个问题?

遵循三步规则:

  1. 首先,尽量不要通过更好的设计来暴露API对象的内部
  2. 如果规则1太贵,请考虑按值返回对象(创建副本)。
  3. 如果这是一个堆分配的对象,请考虑通过shared_pointer返回它,以确保即使你的核心对象被释放也可以访问该引用。

错误#9:使用隐式模板实例化时,使用模板实现细节来混淆公共头文件

在隐式实例化中,模板代码的内部必须放在头文件中。没有其他办法。但是,你可以将模板声明(你的API用户将引用)从模板实例化中分离出来,方法是将实例化放在单独的头文件中,如下所示:

// File: Stack.h ( Public interface)
#pragma once
#ifndef STACK_H
#define STACK_H
#include <vector>
template <typename T>
class Stack
{
public:
  void Push(T val);
  T Pop();
  bool IsEmpty() const;
private:
  std::vector<T> mStack;
};
typedef Stack<int> IntStack;
typedef Stack<double> DoubleStack;
typedef Stack<std::string> StringStack;
// isolate all implementation details within a separate header
#include "stack_priv.h"
#endif
// File: Stack_priv.h ( hides implementation details of the Stack class)
#pragma once
#ifndef STACK_PRIV_H
#define STACK_PRIV_H
template <typename T>
void Stack<T>::Push(T val)
{
  mStack.push_back(val);
}
template <typename T>
T Stack<T>::Pop()
{
  if (IsEmpty())
  {
    return T();
  }
  T val = mStack.back();
  mStack.pop_back();
  return val;
}
template <typename T>
bool Stack<T>::IsEmpty() const
{
  return mStack.empty();
}
#endif

许多高质量的基于模板的API使用此技术,例如各种Boost头文件。它的好处是保持主要公共头文件不受实现细节的影响,同时将内部细节的必要暴露,隔离到明确指定为包含私有细节的单独头文件。

错误#10:当用例已知时,不使用显式模板实例化

为什么这是一个错误?

从API设计的角度来看,隐式实例化受到以下问题的困扰:

  1. 编译器现在负责在适当的位置滞后地实例化代码,并确保只存在该代码的一个副本以防止重复符号的链接错误。这会对你的客户端的构建和链接时间造成影响。
  2. 你的代码逻辑的内部现在暴露出来,这绝不是一个好主意。
  3. 客户端可以用一些你以前没有测试过的任意类型来实例化你的模板,并且会遇到奇怪的失败。

如何解决这个问题?

如果你知道你的模板将只与int、double和string一起使用,你可以使用显式实例化为这三种类型生成模板特化。它缩短了客户端的构建时间,使你不必密封模板中未经测试的类型,并将模板代码逻辑隐藏在cpp文件中。

要做到这一点很简单 - 只需按照以下三个步骤进行:

步骤1:将堆栈模板代码的实现移动到cpp文件中:

在这一点上,让我们尝试实现并使用堆栈的push()方法:

Stack<int> myStack;
myStack.Push(31);

我们会遇到一个连接错误:

error LNK2001: unresolved external symbol "public: void __thiscall Stack<int>::Push(int)" (?Push@?$Stack@H@@QAEXH@Z)

这是链接器告诉我们它在任何地方都找不到push方法的定义。难怪,因为我们还没有实例化它。

步骤2:在cpp文件底部创建int、double和string类型的模板实例:

// explicit template instantiations
template class Stack<int>;
template class Stack<double>;
template class Stack<std::string>;

现在你可以构建和运行堆栈代码了。

步骤3:通过将以下typedef放在头文件的末尾,告诉客户端你的API支持int、double和string的三种限定类型:

typedef Stack<int> IntStack;
typedef Stack<double> DoubleStack;
typedef Stack<std::string> StringStack;

警告:如果进行显式特殊化,客户端将无法创建更多特殊化(并且编译器也无法为客户端创建隐式实例化),因为实现细节隐藏在我们的.cpp文件中。请确保这是你的API的预期用例。

错误#11:在默认函数参数中公开内部值

为什么这是个问题 ?

默认参数通常用于在较新版本中扩展API,以便不会破坏API的向后兼容性来增强功能。

例如,假设你发布了具有以下格式的API:

//Constructor
Circle(double x, double y);

后来,你决定将半径指定为参数将很有用。因此,你发布了一个新版本的API,并将半径作为第三个参数。但是,你不希望破坏现有的使用规则,因此你将半径作为默认参数:

// New API constructor
Circle(double x, double y, double radius=10.0);

通过这种方式,任何使用仅具有x和y坐标的API的客户端都可以继续使用它。这种方法听起来不错。

但是,它有多个问题:

  1. 这将破坏二进制(ABI)兼容性,因为方法的受损符号名称将必须更改。
  2. 默认值将编译到客户的程序中。这意味着如果你使用不同的默认半径发布新版本的API,那么客户端必须重新编译其代码。
  3. 多个默认参数可能导致客户端在使用API时出错。例如,如果你为所有参数(如下所示)提供默认值,则客户端可能会错误地使用不具有逻辑意义的组合,例如提供不带Y值的X值。 Circle(double x=0, double y=0, double radius=10.0); Circle c2(2.3); // Does it make sense to have an x value without an Y value? May or may not !
  4. 最后,当你没有显式指定半径值时,你将公开API的行为。这将很糟糕,因为如果以后添加对不同默认单位概念的支持,例如允许客户端在以米、厘米或毫米为单位指定的值之间切换。在这种情况下,10.0的恒定默认半径将不适用于所有单位。

如何解决这个问题?

提供多个重载方法,而不是使用默认参数。例如,

Circle();
Circle(double x, double y);
Circle(double x, double y, double radius);

前两个构造函数的实现可以使用未指定的属性的默认值。重要的是,这些默认值在.cpp文件中指定,并且不在.h文件中公开。因此,API的更高版本可以更改这些值,而不会对公共接口产生任何影响。

补充说明:

  1. 不需要将所有默认参数实例转换为重载方法。特别是,如果默认参数表示为无效或空值,例如将NULL定义为指针的默认值或将字符串参数定义为“”,那么这种用法在API版本之间不太可能发生变化。
  2. 作为性能说明,你还应该尝试避免定义涉及构造临时对象的默认参数,因为这些参数将按值传递到方法中,因此可能很昂贵。

错误#12:将#Defines用于C ++ API

#defines在C代码中用于定义常量。例如:

#define GRAVITY 9.8f

为什么这是一个错误?

在C ++中,由于以下原因,不应将#defines用于内部常量:

  1. 在公共头文件中使用#define会泄漏实现细节。
  2. #define不为你定义的常量提供任何类型检查,并且可能导致我们对隐式转换和舍入错误感到疑惑。
  3. #define语句是全局的,不限于特定的范围,例如在单个类中。因此它们可以污染客户的全局命名空间。他们必须跳过多步找到#undef。但由于包含顺序依赖性,找到#undef的正确位置可能总是麻烦。
  4. #define没有访问控制。你不能将#define标记为public,protected或private。它基本上是公开的。因此,你无法使用#define指定只能由你定义的基类的派生类访问的常量。
  5. 上面的“GRAVITY”的#defines的符号名称被预处理器删除,因此不会输入到符号表中。这可能会在调试过程中造成巨大的问题,因为这会在客户尝试使用AP调试代码时隐藏客户端的有价值信息,因为他们只会看到调试器中使用的常量值9.8,而没有任何描述性名称。

如何解决这个问题?

在代码中使用静态consts而不是#defines用于简单常量。例如:

static const float Gravity;

更好的是,如果在编译时知道该值,请使用constexpr:

constexpr double Gravity = 9.81;

有关consts vs constexpr的更多详细信息,请访问:https://stackoverflow.com/questions/13346879/const-vs-constexpr-on-variables

在C代码中,有时#defines用于定义网络状态,如:

#define BATCHING 1
#define SENDING 2
#define WAITING 3

在C ++中,始终使用枚举类来执行此操作:

enum class NetworkState { Batching, Sending, Waiting };  // enum class

错误#13:使用友元类

在C ++中,友元关系是你的类授予另一个类或函数的完全访问权限的一种方式。然后,友元类或功能可以访问你类中的所有受保护和私人成员。

虽然这面向面向对象设计和封装,但这在实践中很有用。如果你正在开发一个包含许多组件的大型系统,并希望将一个部件中的功能仅暴露给选定的客户端(测试类),这可以使事情变得更加容易。

实际上,.Net中的[InternalsVisible]属性确实起到了类似的作用。

但是,友元类不应该在公共API中公开。

为什么在C ++中使用friend是个错误?

因为公共API中的友元类可以允许客户端破坏封装并以非预期的方式使用系统对象。

即使我们将内部发现/IP的一般问题放在一边,客户端也可能以非预期的方式使用API,使用他们的系统,然后致电你的支持团队,以解决他们最初不以非预期方式使用API而造成的问题。

那是他们的错吗?不!你的错在于你让他们一开始就把自己的“脚”暴露给了友元类。

怎么解决?

避免在公共API类中使用friend。它们通常是设计不佳的表现,并且允许客户端访问API的所有受保护和私有成员。

错误#14:不避免不必要的include头文件

为什么这是一个错误?

不必要的头文件可以明显增加编译时间。这对于需要在本地使用API构建代码的开发人员而言,不仅会导致浪费时间,而且还会因自动构建代理消耗时间而导致成本高昂,因为这样的代理可能需要每天数千次构建代码。

另外,很有意思的是,拥有独立的规模庞大的头文件会拖累构建并行化系统(如Incredibuild和FastBuild)的有效性。

如何解决这个问题?

  1. 你的API应该只包含它绝对需要编译的头文件。使用前向声明可能很有用,因为: 它减少了编译时间。 打破头文件之间的循环依赖关系是会很有用的。
  2. 使用预编译的头文件也可以显著减少构建时间。

错误#15:对外来(不是你自己的)对象类型使用前向声明

为什么这是一个错误?

对不属于你的API对象使用前向声明可能会以意外方式中断客户端代码。例如,如果客户端决定更新到不同版本的外部API头,则如果前向声明的类已更改为typedef或模板类,那么前向声明将中断。

从另一个角度来看,如果你从一个外部头文件向前声明一个类,你基本上会锁定你的客户端总是使用你声明的外部头文件的版本,所以基本上他不能再升级那个外来依赖了!!!

如何解决这个问题?

你只应在你的API中向前声明符合。此外,永远不要向前声明STL类型等等。

有关此主题的更多讨论,请参阅stackoverflow上的此问题:https://stackoverflow.com/questions/47801590/what-are-the-risks-to-massively-forward-declaration-classes-in-header-files

错误#16:不让头文件必须自行编译

头文件应该具有自己编译所需的一切,即它应该显式地#include或forward声明它需要编译的类型/结构。

如果一个头文件不具备编译所需的全部功能,但是包含该头文件的程序正在编译,则表明由于包含顺序依赖性,头文件以某种方式获得了所需的功能。这通常是因为另一个include头文件在未编译的头文件之前在编译链中,并且为这个未编译的头文件提供了缺失的功能。

如果include的order/build顺序依赖关系发生更改,则整个程序可能会以意外方式中断。C ++编译器因误导错误消息而臭名昭著,并且可能不容易在此时找到错误。

如何解决这个问题?

利用testMain.cpp通过隔离编译方式来检查头文件内容,testMain.cpp除了包含测试的头文件之外什么也没有。如果它产生编译错误,那么需要将某些内容include在头文件中或向前声明。对项目中的所有头文件重复该过程,使用自下而上的方法。随着代码库变大和代码块的移动,这将有助于防止随机构建的中断。

错误#17:没有为你的API提供版本控制信息

客户端应该能够在编译时和运行时检查API的哪个版本集成到他们的系统中。如果缺少此类信息,他们将无法采取有效的更新/补丁。

同样,在不同平台上添加代码的向后兼容性也很困难。

此外,产品的版本号是我们的升级工程师在向用户回答问题时首先要求的。

错误#18:从一开始就没有决定静态或动态库的实现

无论你的客户更喜欢静态库还是动态链接库,都应该决定你的很多设计选择。例如:

  1. 你可以在API接口中使用STL类型吗?如果你将产品作为静态库传递,但如果使用动态库,则可能会导致平台类型和编译器版本的二进制文件激增。如果传递DLL,可能更偏好扁平的C风格API。
  2. 你有多少功能进入API?对于静态库,你需要更少的担心,因为只有归档中所需的目标文件才链接到可执行文件中。另一方面,对于DLL,即使客户端使用5%的DLL功能,整个DLL也会被加载到效率低下的进程空间中。因此,如果你正在使用DLL方法,则可能更好地分解多个DLL中的功能,例如,对于Math库,你可能希望从三角函数库中分离微积分库等等。

怎么避免这个?

这没有什么神奇之处,它可以归结为简单的旧需求收集,只需确保在讨论的早期阶段与你的客户端提出静态或者动态库的含义。

错误#19:没有认识到ABI的兼容性

维基百科定义应用程序二进制接口(ABI),这是两个二进制程序模块之间的接口;通常,这些模块中的一个是库或操作系统工具,另一个是由用户运行的程序。

如果动态链接到库的以前版本的程序继续与较新版本的库一起运行而不需要重新编译,那么此时库是二进制兼容的。

二进制兼容性可以节省很多麻烦。它为特定平台分发软件变得更加容易。如果不确保版本之间的二进制兼容性,人们将被迫提供静态链接的二进制文件。静态二进制文件很糟糕,因为它们浪费资源(尤其是内存)不允许程序从库中的错误修复或扩展中受益。Windows子系统被打包为DLL的集合是有原因的,这使得这些Windows更新或修补变得轻而易举,好吧,也许不是真的,但这是因为其他问题。

例如,以下是两个不同函数的特殊名(即用于标识对象或库文件中的函数的符号名):

// version 1.0
void SetAudio(IAudio *audioStream) //[Name Mangling] ->_Z8SetAudioP5Audio
// version 1.1
void SetAudio(IAudio *audioStream, bool high_frequency = false) // [Name Mangling] ->_Z8SetAudioP5Audiob

这两种方法是源兼容的,但它们不是二进制兼容的,正如每种方法产生的不同的特殊名所证明的那样。这意味着针对1.0版编译的代码不能简单地使用1.1版库,因为不再定义_Z8SetAudioP5Audio符号。

如何兼容ABI?

首先,熟悉ABI兼容和ABI突破性变化。然后,按照Martin Reddy在他的书中提供的附加指导:

  1. 使用flat C风格的API可以更容易实现二进制兼容性,因为C不提供继承、可选参数、重载、异常和模板等功能。例如std :: string的使用在不同的编译器之间可能不是二进制兼容的。为了充分利用这两个方面,你可以决定使用面向对象的C ++样式开发API,然后提供C ++ API的扁平C样式包装。
  2. 如果确实需要进行二进制不兼容的更改,则可以考虑以不同方式命名新库,以免破坏现有应用程序。这种方法由libz库采用。版本1.1.4之前的版本在Windows上称为ZLIB.DLL。但是,二进制不兼容的编译器设置用于构建库的更高版本,因此库已重命名为ZLIB1.DLL,其中“1”表示API主版本号。
  3. pimpl idom可用于帮助保持接口的二进制兼容性,因为它将所有实现细节(将来最有可能更改的元素)移动到.cpp文件中,它们不会影响公共.h文件。
  4. 你可以定义方法的新重载版本,而不需要向现有方法中添加参数。这可以确保原始符号继续存在,但也提供了较新的调用约定。在.cpp文件中,可以通过简单地调用新的重载方法来实现旧方法。

错误#20:向已发布的类API添加纯虚方法

为什么这是一个错误?

请考虑以下代码:

class SubClassMe
{
  public:
    virtual ~SubClassMe();
    virtual void ExistingCall() = 0;
    virtual void NewCall() = 0; // added in new release of API
};

对于所有现有的客户端来说,这是一个破坏API的更改,因为现在他们必须为这个新方法定义一个实现,否则他们的派生类将不具体,他们的代码也不会编译。

如何解决这个问题?

修复很简单,那就是为添加到抽象类中的任何新方法提供一个默认实现,即使它们成为虚的也不会是纯虚的。

class SubClassMe
{
  public:
    virtual ~SubClassMe();
    virtual void ExistingCall() = 0;
    virtual void NewCall(); // added in new release of API
};

错误#21:不记录API是同步还是异步

考虑公共头文件中的以下代码段:

static void ExecuteRequest(CallRequestContainer& reqContainer);

当我看到这个时,我完全不知道这个方法是立即返回(异步)还是阻塞(同步)。我如何以及在何处使用此代码,这对产生了极大的影响。例如如果这是一个同步调用,我永远不会在像游戏场景渲染循环这样的时间关键代码路径中使用它。

如何解决这个问题?

1.采用更近的C ++ 11层的功能,如在未来返回值立即指示这是一个异步方法。

std::future<StatusCode> ExecuteRequest(CallRequestContainer& reqContainer);

2.在方法名称上附加上“Sync”或“Async”关键词。

static void ExecuteRequestAsync(CallRequestContainer& reqContainer);

3.关于它的同步或异步行为,在头文件中的方法上有足够的文档。

错误#22:没有使用平台/编译器支持的最低公共特性

你应该始终对客户主要使用的编译器/ C ++标准有一个很好的了解。例如如果你知道许多客户正在为使用C ++ 11的现有产品添加功能,那么请不要依赖任何C ++ 14功能。

我们最近向我们提交了支持请求,其中客户端使用的是旧版Visual Studio,而C ++ 14函数make_unique不可用。我们必须为客户端进行条件编译修复,幸运的是,这只是在几个地方。

错误#23:不考虑开源项目的头文件实现

如果你将API作为源代码分发,请考虑使用仅标头库。

分发仅包含头库有几个优点:

  1. 你不必担心为不同的平台和不同的编译器版本分发.lib和.dll或者 .so文件。这极大地减少了你的构建和分发逻辑。
  2. 你的客户可以完全访问源代码。
  3. 你的客户节省了必须编译二进制文件的额外步骤,并确保它使用与其exe相同的设置(例如CRT链接、迭代器调试级别、线程模型......)。
  4. 客户节省了打包二进制文件的成本。对于像Unreal这样的游戏引擎来说,打包二进制文件可能会非常麻烦。
  5. 有些情况下,只有头文件是唯一的选项,例如在处理模板时(除非你选择通过显式实例化为特定类型专门化模板)

这是许多开源项目使用的非常流行的模型,包括Boost和RapidJson。

错误#24:参数类型不一致

这是最近对我们继承的一些遗留代码的审核的一部分(因为隐私更改了真实的代码)。

头文件具有以下typedef:

typedef Stack<int> IntStack;
typedef Stack<double> DoubleStack;
typedef Stack<std::string> StringStack;

在代码库中分散了一些没有显式使用typedef和Stack <T>类型的方法。公共方法之一,如果我记得正确的话,具有以下格式:

void CheckStackFidelity(IntStack testIntStack, Stack<std::string> testStringStack);

如何解决这个问题?

如果你选择typedef版本或非typedef版本并不重要。关键是“STAY CONSISTENT”,只需约定并坚持选择一个。

错误#25:没有API审核流程!

在开发过程的早期,我经常看到并亲自表示没有进行API审核。这是因为没有任何结构化的指令来进行API审核。

我发现当没有流程时会出现多个问题,包括:

  1. 该API不符合Beta客户的使用案例(通常情况下,人们会等到Beta客户后再查看API。)
  2. API与系统的其他部分或同一系列产品不相似。
  3. API有法律/合规/营销问题。我们遇到过这样一种情况:其中一个API的命名不是很合适。

市场需要它,它导致了很多后期重构和延迟。

如何解决这个问题?

为了避免上面指出的几种麻烦,你应该建立一个至少执行以下操作的过程:

  1. API应在实际编码开始之前预先设计。在C ++上下文中,这通常是带有相关用户文档的头文件。
  2. 所有利益相关方都应审核API,包括合作伙伴团队、Beta(私人预览客户)、营销、法律和开发人员(如果贵公司有)。
  3. 在私人预览前几个月与#2中的所有利益相关者进行另一次API审核,以确保他们感到高兴。
  4. 明确告知,任何API更改都是昂贵的私有预览,人们应该在开发的早期阶段提出他们的建议。

好吧,这些就是我注意到的C ++ API的Top25的错误。这份清单并不全面,所以你一定要拿一本Martin Reddy的书来深入了解这个主题。

祝API Review快乐!!!

原文发布于微信公众号 - 小白学视觉(NoobCV)

原文发表时间:2019-08-30

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券