《API Design for C++》读书笔记(二):API特征

目录


本章所讲的内容都是在回答下面这个问题:优质的API应该具有哪些基本特征?


1、问题域建模

编写API的目的是解决特定的问题或是完成具体的任务。因此,API应该首先为问题提供一个清晰的解决方案,同时能对实际的问题域进行准确的建模。

1.1 提供良好的抽象

API应该对它所解决的问题提供逻辑抽象。也就是说,在设计API时,应该阐述在选定问题域内有意义的深层概念,而不是公开底层实现细节。

1.2 关键对象的建模

API同样需要对问题域的关键对象建模。该过程旨在描述特定问题域中对象的层次结构,因此经常被称作“面向对象设计(OOD)”或者“对象建模”。对象建模的目的是确定主要对象集合(如一个播放器的主要对象可能有容器对象,解码器对象,输出渲染对象),这些对象提供的操作以及对象之间的关系。

2、 隐藏实现细节

创建API的主要原因是隐藏所有的实现细节,以免将来修改API对已有客户造成影响。因此API最重要的特征就是要切实达到这一目标。

2.1 物理隐藏: 声明与定义

在c/c++中,声明和定义是有特定含义的精确术语。声明只是告诉编译器一个名字以及它的类型,并不为其分配任何内存。与之相对,定义提供了类型结构体的谢姐,如果是变量则为其分配内存。(c程序员所使用的术语“函数原型”与术语“函数声明”是等价的。)例如,一下都是声明:

extern int i;
class MyClass;
void MyFunc(int value);

而以下都是定义:

int i = 0;
class MyClass
{
public:
    float x,y,z;
};
void MyFunc(int value)
{
    printf("In MyFunc(%d)\n", value);
}

这种物理隐藏的具体做法就是,在.h文件中声明API的接口,类,变量等,在.c/cpp中实现这些接口,类等。

2.2 逻辑隐藏:封装

封装(面向对象中的一个概念)提供了限制访问对象成员的机制(我的理解:非面向对象语言如c,也一样可以实现封装(如c中的结构体,void*指针,模块等),只是具体做法会有些差异而已,但在思想和目的上都是一样的)。
在封装的基础上又会有如下一些可能需要隐藏的东西:
- 隐藏成员变量:就是不能让外部直接访问类的成员变量(也就是将其放入private字段)
- 隐藏实现方法:一个类中可能会将一个API的实现划分为几个步骤或子模块并将其封装成不同的子函数,而这些子函数通常是不希望外部能够直接访问的(也就是将这些子函数放入private字段)
- 隐藏实现类:和隐藏实现方法同样的道理,可能一个API类会使用几个子类来共同实现,而这些实现也是不希望外部可以访问和使用的(也就是说应该将其放入private字段)

3、最小完备

优秀的API设计应该是最小完备的。即它应该尽量简洁,但不要过分简洁。

3.1 不要过度承诺

API中每一个共有元素(类,接口等)都是一项承诺,它承诺了该功能在API的生命周期中都像得到支持。虽然你可以背弃某项承诺,但是此举会令客户受挫并迫使其重写代码。更糟糕的是,由于你无法保证API的稳定迫使用户不停的修正代码,或者由于你移除了支持其独特用例的功能导致他们无法使用API,那么用户很可能会弃用你的API。

3.2 谨慎添加虚函数

继承(将某个成员函数设置为虚函数)暴露出的功能可能会超出预期。而且这种方式并不容易察觉。
虽然继承十分强大(比如客户可以通过继承API中的类以实现客户自己的某些功能或流程),但仍要意识到其潜在的隐患:
- 对基类看似无害的修改可能会给客户带来不利影响;
- 客户可能会以你意想不到的方式使用API;
- 客户可能采用不正确或易于出错的方式扩展API;
- 重写函数可能破坏类的内部完整性(比如某个虚函数的默认实现会更新类的某些状态,但子类中的实现却未去更新这些状态,那样就会出现异常);
- 虚函数的调用是在运行时查询虚表决定具体该调用哪一个函数,这样就比普通函数调用慢(当然在调用不频繁的情况下可以忽略不计);
- 使用虚函数需要维护虚表,进而增大了对象的大小;
- 添加、重排或移除虚函数都会破坏二进制兼容性,因这都打乱了虚函数在虚表中的位置;
- 不是所有虚函数都能内联,因而将虚函数声明为内联是没有任何意义的;
- 重载虚函数需要一定的技巧(因为基类中的一组重载的虚函数会被子类中的一个覆盖函数锁隐藏)。

3.3 便捷API

简化API是一项困难的任务。在减少API函数数目与是API易于各种客户使用之间存在天然的矛盾(即,是让API简洁易用,还是让API足够详细,功能多样)。

4、 易用性

优秀的API设计应该让简单的任务更简单,使人一目了然。当然,不能因此就忽视优秀的支持文档的重要性。事实上,这应该让编写文档的工作变得更容易。

使API易于理解的各方面的技巧如下:
- 可发现性(discoverable):可发现的API要求用户能够通过API明白如何使用它们,而不需要参阅任何解释或文档。虽然可发现性并不一定能带来易用性,但一般而言,可发现性有益于产生更加好用的接口。
- 不易误用:优秀的API不仅易于使用,而且还要不易误用。最常见的误用就是像方法传递错误的参数或非法值。
- 一致性: 优秀的API应该采用一致性的设计方法,以便于用户记住其风格,进而更容易被用户采用(如:命名约定,参数顺序,标准设计模式的使用,内存模型语义,异常使用,错误处理等 )

  • 正交:在API设计中,正交性意味着方法没有副作用。调用设定特定属性的方法应该仅改变那个属性而不能额外改变其他可以公共访问的属性。
  • 健壮的资源分配:内存管理是c/c++编程中最富技巧性的方面之一。大部分的c++错误都是由于误用指针或引用所导致的:

    • 对NULL解引用(尝试对NUL指针使用->或*操作)
    • 二次释放
    • 访问非法内存区域
    • 混用内存分配器(如new对free,malloc对delete)
    • 数组释放不正确(使用delete而非delete []释放数组)
    • 内存泄漏
      当然这些指针问题可以使用托管指针之类的来避免(如共享指针,弱指针,作用域指针等)
  • 平台独立:设计精良的c++ API不应该在公共头文件中出现平台相关的#if/#ifdef语句。如果API给出了问题域的高层次的逻辑模型(API本身就应该如此),那么API几乎不会因平台异。只有在编写使用专用平台资源的接口时,比如某个程序绘制一个窗口,并为本地操作系统传递正确的窗口句柄,这样才合适。

5、 松耦合

耦合和内聚的概念如下:
- 耦合:软件组件之间相互连接在一起的度量,即系统中每个组件对其他组件的依赖程度。
- 内聚:单个软件组件内的各种方法相互关联或聚合强度的度量。

优秀的软件设计应该是低耦合(或松耦合)且高内聚的,即最小化不同组件之间功能的关联性和连通性。评估组件之间的耦合度可采用下面几种不同的度量方式:
- 尺度:与组件之间的连接数相关,包括类的数目,方法的数目,每个方法的参数的数目等。
- 可见度:指组件之间的连接显著程度。
- 密切度:指组件之间连接的直接性。
- 灵活度:与改变组件之间连接的难易程度相关。

降低API内的类和方法的耦合度(API内耦合度)的技巧:

5.1 仅通过名字耦合

具体形式如下:

class MyObject; //只需要知道MyObject的名字
class MyObjectHolder
{
public:
    MyObjectHolder();
    void SetObject(MyObject* obj);
    MyObject* GetObject() const;
private:
    MyObject *mObj;
};

5.2 降低类耦合

Scott Meyers建议,如果情况允许,那么优先声明非成员、非友元的函数,而非成员函数。这样做在促进封装的同时还降低了这些函数和类的耦合度。例如

class MyObject
{
public:
    void PrintName() const; 
    std::string GetName() const;
    ...
private:
std:string mName;
};

依照Meyers的建议。应该优先使用一下表述:

class MyObject
{
public:
    std::string GetName() const;
    ...
private:
std:string mName;
};

void PrintName(MyObject& obj); 

后一种形式降低了耦合度,因为自由函数PrintName只能访问MyObject的共有方法。

5.3 可以的冗余

通常,优秀的软件工程实践的目标是去除冗余,即保证每个重要的知识点或行为有且仅有一次实现。但有时,使用数据冗余降低类之间的耦合是合理的。例如有一个文字聊天系统的API,该系统记录用户发送的每条信息。

class TextChatLog
{
public:
    bool AddMessage(const ChatUser& user, const std::string& msg);
    ...
private:
    struct ChatEvent
    {
        ChatUser mUser;
        std::string mMessage;
        size_t mTimestamp;
    };
    std::vector<ChatEvent> mChatEvents;
};

这里显然类TextChatLog和ChatUser是耦合的,如果最终TextChatLog类仅使用了类ChatUser中的用户名(即调用ChatUser::getName()方法),那么去除这两个类的方法就是,在AddMessage方法中只传入user name而非ChatUser类的对象。

class TextChatLog
{
public:
    bool AddMessage(const std:string& userName, const std::string& msg);
    ...
private:
    struct ChatEvent
    {
        std::string mUserName;
        std::string mMessage;
        size_t mTimestamp;
    };
    std::vector<ChatEvent> mChatEvents;
};

虽然这样一来,类ChatUser和TextChatLog中都保存了userName这个数据,但这却消除了两个类的耦合。

5.4 管理器类

就是通过一类(管理器类)来拥有并协调低层次的类。比如一个结构化的画图程序,他可以创建二维对象,选择对象以及在画布上移动对象,但这个程序支持多种输入设备,如鼠标,手写板,游戏手柄等。一种简单的设计就是要求选择和移动操作了解每种输入设备(即,直接与每一种输入设备耦合)。另一种就是通过一个管理器类来协调对每个输入设备类的访问。这样选择和移动类就只需要依赖管理器类,从而去除了与低层类的直接耦合。(具体例子,请参见书中 2.5.4 管理器类)

5.5 回调、观察者和通知

  • 回调函数:在c/c++中,回调是模块A中的一个函数指针,该指针被传递给模块B,这样B就能在合适的时候调用A中的函数。模块B对函数A一无所知,并且对模块A不存在“包含(include)”或者“链接(link)”依赖。 回调的这种特性使得低层代码能够执行与其不能有依赖关系的高层代码。因此,在大型项目中,回调是一种用于打破循环依赖的常用技术。

  • 观察者:回调给出的解决方法在纯C中能够正常工作,而正如前面提到的,如果不能使用类似boost::bind(类似java中的反射) 的辅助功能, 在面向对象的c++程序中使用回调是非常复杂的。相比之下,更加面向对象的解决方法是使用观察者的概念(即观察者模式)

  • 通知:回调和观察者适用于特定的任务,他们的使用机制通常定义在执行实际回调的对象中。一个替代的解决方案是,在系统中不连通的部分之间构建几种发送通知机制或事件。发送者事先不需要知道接收者,这样可以降低发送者和接收者之间的耦合度。虽然通知机制有好几种,但是最流行的是信号和槽(由Qt库引入的概念)。

6 稳定的、文档详细且经过测试的API

  • 优秀的API设计应该是稳定的且具有前瞻性。
  • 优秀的API应该有很好的文档支持,以便用户获取API的功能、行为、最佳实践以及错误条件的明确信息。
  • 应该为API的实现编写可扩展的自动化测试程序,确保新的变更不会破坏现有的用例。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值