当前位置:   article > 正文

排名前25位的C++API设计错误以及如何避免它们_c++ api 设计的 25 个错误以及如何避免

c++ api 设计的 25 个错误以及如何避免

原文链接:https://www.acodersjourney.com/top-25-cplusplus-api-design-mistakes-and-how-to-avoid-them/
作者:Deb Haldar

对于许多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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

如果有人试图在也使用std::vector的项目中使用此类,则会收到错误“error C2872: ‘vector’: ambiguous symbol”。这是因为编译器无法确定客户端代码引用的向量 - std::vector或Location.h中定义的向量对象。

如何解决这个问题?
始终将你的API放在自定义命名空间中,如:

//API
namespace LocationAPI
{
  class vector
  {
  public:
    vector(double x, double y, double z);
  private:
    double xCoordinate;
    double yCoordinate;
    double zCoordinate;
  };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

另一种方法是为所有公共API符号添加一个唯一的前缀。如果遵循此约定,我们将调用我们的类“lvector”而不是“vector”。此方法用于OpenGL和QT。 在我看来,如果你正在开发纯C API,这是有道理的。确保所有公共符号符合此唯一命名约定是另一个令人头痛的问题。如果你使用的是C++,那么你应该将API功能分组到命名空间中,让编译器为你完成繁重的工作。 我还强烈建议你使用嵌套命名空间来分组功能或将公共API与内部API分开。一个很好的例子是The Boost库,它们可以自由地使用嵌套的命名空间。例如,在根“boost”命名空间内,boost::variant包含Boost Variant API的公共符号,boost::detail::variant包含该API的内部详细信息。

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

为什么这是一个错误?
这将导致引用的命名空间中的所有符号在全局命名空间中变得可见,并且首先消除了使用命名空间的好处。
另外:

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

如何解决这个问题?

  1. 尽量避免在头文件中放置任何使用的命名空间声明。如果你绝对需要一些名称空间对象来编译,请在头文件中使用完全限定名称(例如std::cout,std::string)。
//File:MyHeader.h:
class MyClass
{   
private:
 Microsoft::WRL::ComPtr _parent;
 Microsoft::WRL::ComPtr _child;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  1. 如果上面的建议#1导致代码混乱太多 - 将“使用命名空间”用法限制在头文件中定义的类或命名空间内。另一个选项是在头文件中使用范围别名,如下所示。
//File:MyHeader.h:
class MyClass
{
namespace wrl = Microsoft::WRL; // note the aliasing here !
private:
 wrl::ComPtr _parent;
 wrl::ComPtr _child;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

错误#3:无视三法则

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

为什么忽略三个规则是一个错误?
如果你定义它们中的任何一个,很可能你的类正在管理一个资源(内存,fileHandle,套接字等)。从而:

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

让我们看一个例子 - 在下面的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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

那么这里发生了什么?
客户端通过构造函数在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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

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

// 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;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

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

三规则现在已转换为5的规则,以考虑移动构造函数和移动赋值运算符。所以在我们的例子中,如果我们要使类不可复制和不可移动,我们将拷贝构造函数和赋值运算符标记为已删除。

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;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

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

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

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

为什么这是一个错误?
如果该构造函数不破坏其强大的异常安全保证,则STL容器只能在其调整大小操作中使用拷贝构造函数。例如,std::vector不会使用你的API对象的拷贝构造函数,如果它可以抛出异常。这是因为如果在拷贝中抛出异常,则正在处理的数据可能会丢失,而在复制构造函数中,原始文件将不会更改。 因此,如果你没有在API中将拷贝构造函数和赋值运算符标记为noexcept,则如果客户计划使用STL容器,则可能会对你的客户产生深刻的性能影响。本文显示,与可移动的类相比,无法移动的类花费大约两倍的时间放置在向量中并遇到不可预测的内存峰值。

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

class Tool
{
public:
  Tool(Tool &&) noexcept;
};
  • 1
  • 2
  • 3
  • 4
  • 5

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

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

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

怎么解决?
只需标记不作为noexcept抛出的API。

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

为什么这是一个API设计错误?
允许编译器进行一次隐式转换以将参数解析为函数。这意味着编译器可以使用单个参数调用的构造函数将一种类型转换为另一种类型,以获得正确的参数类型。
例如,如果我们在LocationAPI中有以下单个参数构造函数:

namespace LocationAPI
{
 class vector
  {
 public:
    vector(double x);
    // .....
  };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

我们可以调用以下代码:

LocationAPI::vector myVect = 21.0;
  • 1

这将使用21.0的double参数调用vector单参数构造函数。但是,这种类型的隐式行为可能会令人困惑,不直观,并且在大多数情况下是无意的。
作为此类不需要的隐式转换的另一个示例,请考虑以下函数签名:

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

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

CheckXCoordinate(20.0, 20.0);
  • 1

这会削弱API的类型安全性,因为现在编译器不会将第一个参数的类型强制为显式向量对象。
结果,用户有可能忘记正确的参数顺序并以错误的顺序传递它们。

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

class vector
{
public:
 explicit vector(double x);
  //.....
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

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

如何解决这个问题?
当你的API需要对客户端数据进行只读访问时,请将API方法和/或参数标记为const。 假设你需要一个函数来只检查两个坐标是否相同。

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

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

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

错误#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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
// 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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

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

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

为什么这是一个错误?
从API设计的角度来看,隐式实例化受到以下问题的困扰:

  1. 编译器现在负责在适当的位置懒惰地实例化代码,并确保只存在该代码的一个副本以防止重复的符号链接错误。这会对你客户的构建和链接时间造成影响。
  2. 现在公开了代码逻辑的内部结构,这绝不是一个好主意。
  3. 客户端可以使用你之前未测试过的某种仲裁类型来实例化你的模板,并遇到奇怪的故障。

如何解决这个问题?
如果你知道你的模板将只与int,double和string一起使用 - 你可以使用显式实例化为这三种类型生成模板特化。它缩短了客户的构建时间,使你不必密封模板中未经测试的类型,并将模板代码逻辑隐藏在cpp文件中。 要做到这一点很简单 - 只需按照以下三个步骤进行:
第1步:将堆栈模板代码的实现移动到cpp文件中 在这一点上,让我们尝试实现并使用堆栈的push()方法,

Stack<int> myStack;
myStack.Push(31);
  • 1
  • 2

我们会收到链接器错误:

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

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

// explicit template instantiations
template class Stack<int>;
template class Stack<double>;
template class Stack<std::string>;
  • 1
  • 2
  • 3
  • 4

现在,你将能够构建和运行堆栈代码。
第3步:通过在头文件的末尾添加以下typedef,告诉客户端你的API支持int,double和string的三个特性:

typedef Stack<int> IntStack;
typedef Stack<double> DoubleStack;
typedef Stack<std::string> StringStack;
  • 1
  • 2
  • 3

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

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

为什么这是个问题 ?
默认参数通常用于在较新版本中扩展API,以便不会破坏API的向后兼容性来增强功能。
例如,假设你发布了具有以下签名的API:

//Constructor
Circle(double x, double y);
  • 1
  • 2

稍后你决定将radius指定为参数将非常有用。因此,你将使用radius作为第三个参数发布新版本的API。但是,你不希望破坏现有客户端 - 因此你将radius作为默认参数:

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

通过这种方式,任何使用仅具有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 !
  • 1
  • 2
  1. 最后,当你未明确指定半径值时,你将公开API的行为。这将是不好的,因为如果你稍后添加对不同默认单位的概念的支持,让用户在以米,厘米或毫米指定的值之间切换。在这种情况下,恒定的默认半径10.0将不适合所有单位。

如何解决这个问题?
提供多个重载方法,而不是使用默认参数。例如,

Circle();
Circle(double x, double y);
Circle(double x, double y, double radius);
  • 1
  • 2
  • 3

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

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

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

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

#define GRAVITY 9.8f
  • 1

为什么这是一个错误?
在C++中,你不应将#defines用于内部常量,原因如下:

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

如何解决这个问题?
在代码中使用静态consts而不是#defines用于简单常量。例如:

static const float Gravity;
  • 1

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

constexpr double Gravity = 9.81;
  • 1

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

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

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

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

错误#13:使用友元类

在C++中,friend是你的类授予另一个类或函数的完全访问权限的一种方式。然后,友元类或函数可以访问你类的所有受保护和私人成员。
虽然这违背了面向对象设计和封装,但这在实践中很有用。如果你正在开发一个包含许多组件的大型系统,并希望将一个组件t中的功能仅暴露给选定的客户端(测试类?),这可以使事情变得更加容易。
实际上,.Net中的[InternalsVisible]属性确实起到了类似的作用。
但是,不应在公共API中公开好友类。

为什么在C++中使用friend是个错误?
因为公共API中的友元可以允许客户端破坏封装并以非预期的方式使用系统对象。
即使我们解决了内部发现的一般问题,客户端也可能以非预期的方式使用API​​,使用他们的系统,然后通过不以非预期的方式调用你的支持团队来解决他们创建的问题。
那是他们的错吗?不!允许他们揭露友元类是你的错。

怎么解决?
避免在公共API类中使用friend。它们往往表明设计不佳,并且允许用户访问API的所有受保护和私有成员。

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

为什么这是一个错误?
不必要的头文件可以显着增加编译时间。这不仅会导致需要在本地使用API​​构建代码的开发人员浪费时间,而且还会因自动构建代理消耗周期而导致成本高昂,这可能需要每天数千次构建代码。
另外,有趣地说,拥有大型单片头会损害构建并行化系统(如Incredibuild和FastBuild)的有效性。

如何解决这个问题?

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

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

为什么这是一个错误?
对不属于你的API对象使用前向声明可能会以意外方式中断客户端代码。例如,如果客户端决定移动到不同版本的外部API头,则如果前向声明的类已更改为typedef或模板化类,则前向声明将中断。
从另一个角度来看,如果你从一个外部标题前向声明一个类,你基本上会锁定你的客户端总是使用你声明的外部标题的版本 - 所以基本上他不能再升级那个外来依赖了!! !

如何解决这个问题?
你只应前向声明来自API的声明符号。此外,永远不要前向声明STL类型等。

错误#16:写的头文件不自包含所有依赖

头文件应该具有自己编译所需的一切,即它应该显式地#include或前向声明它需要编译的类型/结构。
如果头文件没有它需要编译的所有内容,但是包含头文件的程序编译,则表明由于包含顺序依赖性,头文件以某种方式获得它所需的内容。这通常是因为在这个提供缺少功能的无法编译的头文件之前,另一个头文件包含在编译链中。
如果包含顺序/构建顺序依赖关系发生更改,则整个程序可能会以意外方式中断。 C++编译器因误导错误消息而臭名昭著,并且可能不容易在该点找到错误。

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

错误#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
  • 2
  • 3
  • 4

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

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

  1. 使用扁平C风格API可以更容易实现二进制兼容性,因为C不提供继承,可选参数,重载,异常和模板等功能。例如,std::string的使用在不同的编译器之间可能不是二进制兼容的。为了充分利用这两个方面,你可以决定使用面向对象的C++样式开发API,然后提供C++ API的扁平C样式包装。
  2. 如果确实需要进行二进制不兼容的更改,则可以考虑以不同方式命名新库,以免破坏现有应用程序。这种方法由libz库采用。版本1.1.4之前的版本在Windows上称为ZLIB.DLL。但是,二进制不兼容的编译器设置用于构建库的更高版本,因此库已重命名为ZLIB1.DLL,其中“1”表示API主版本号。
  3. The 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
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这是所有现有客户端的API更改,因为现在他们必须为这个新方法定义一个实现,否则它们的派生类将不具体,并且它们的代码将无法编译。

如何解决这个问题?
修复很简单 - 为添加到抽象基类的任何新方法提供默认实现,即使其成为虚拟但不是纯虚拟。

class SubClassMe
{
 public:
 virtual ~SubClassMe();
 virtual void ExistingCall() = 0;
 virtual void NewCall(); // added in new release of API
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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

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

static void ExecuteRequest(CallRequestContainer& reqContainer);
  • 1

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

如何解决这个问题?
有几件事可以帮助:

  1. 使用更新的C++ 11特性(如futrues中的返回值)会立即表明这是一种异步方法。
std::future<StatusCode> ExecuteRequest(CallRequestContainer& reqContainer);
  • 1
  1. 使用“同步”或“异步”关键字附加方法名称
static void ExecuteRequestAsync(CallRequestContainer& reqContainer);
  • 1
  1. 关于它的同步或异步行为,在头文件中的方法上有足够的文档。

错误#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;
  • 1
  • 2
  • 3

在代码库中分散了一些没有使用typedef并显式使用Stack 类型的方法。其中一个公共方法,如果我正确地重新收集,则具有以下签名:

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

如何解决这个问题?
如果你选择typedef版本或非typedef版本并不重要。关键是“保持一致” - 只需选择一个约定并坚持下去。

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

在开发过程的早期,我经常看到并对没有进行API审核怀有个人罪恶感。这是因为没有任何结构化的指令来进行API审核。
我发现当没有流程时会出现多个问题,包括:

  1. API不符合Beta客户的用例(通常人们在API审核之前等待Beta)
  2. API与系统的其他部分或同一产品系列不相似。
  3. API具有法律/合规/营销问题。
    我们遇到过这样一种情况:API以某种方式命名,但与方式不一致 营销需要它,它导致了很多后期重构和延迟。

如何解决这个问题?
为了避免上面指出的麻烦类型,你应该建立一个至少执行以下操作的过程:

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

好吧 - 那些是我关注C++ API的前25个错误。这份清单并不全面 - 你一定要拿一本Martin Reddy的书来深入了解这个主题。

程序员开源交流QQ群 792272915

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/2023面试高手/article/detail/329610
推荐阅读
相关标签
  

闽ICP备14008679号