工程实践:C++接口设计指北

Posted CodeBowl

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了工程实践:C++接口设计指北相关的知识,希望对你有一定的参考价值。

最近在工作中,需要将代码封装成库,供其他方调用。在其中涉及到如何设计接口类,第一次接触,将总结和经验记录下来。

导读

为什么本文叫做《工程实践:C++的接口设计》,是因为,我们大部分人入门的时候,都是调用别人封装好的库函数,却没有尝试过自己封装库给别人用。但是在正常工作中,也就是工程化中,我们会经常封装库给其他人应用,这里面会涉及到怎么封装一个函数,提供一个优秀的接口。
接口:类暴露出来的部分,是类所提供的功能。

接口设计准则

我们在工程化的过程中,设计接口一般遵守以下几个准则:

  1. 单一功能原则
    一个class就其整体应该只提供单一的服务。如果一个class提供多样的服务,那么就应该把它拆分,反之,如果一个在概念上单一的功能却由几个class负责,这几个class应该合并。
  2. 开放/封闭原则
    一个设计并实现好的class,应该对扩充的动作开放,而对修改的动作封闭。也就是说,这个class应该是允许扩充的,但不允许修改。如果需要功能上的扩充,一般来说应该通过添加新类实现,而不是修改原类的代码。添加新类不单可以通过直接继承,也可以通过组合。
  3. 最小惊讶原理
    在重载函数,或者子类实现父类虚函数时,应该基本维持函数原来所期望的功能。

接口设计注意事项

  1. 过度封装
    很多人喜欢这样封装,把接口局限在仅仅解决一个特定的问题上面,失去了代码的灵活性。而且,也容易出现面条代码,让人不知所云。一个接口被写的仅仅用于解决当前问题,当试图增加其扩展性时,发现为时已晚。
    为了防止过度封装,在设计接口的时候,我们应该考虑以下几个问题:

1.我们需要解决什么问题
2.问题的核心是什么
3.应该怎样设计,可以方便客户程序员扩展

  1. 起名要见名知意
    一个好的的接口方法名,是接口设计中成功的一半。

  2. 不要让使用者进行过多工作
    如果使用者使用我们的接口时,进行了过多的准备,那么对我们来说就是失败的。
    所以,设计的时候要记得尽可能简化客户程序员逻辑,使接口设计能够看起来简洁、漂亮,而不至于被接口的复杂性所吓倒。

  3. 简洁
    这个比较好理解,举个例子,你一定见过一个函数使用,需要传进去五六个参数,但是对我们有用的往往只有那么一俩个。
    所以,简洁设计是接口设计的一个重要原则。可以在接口的内部实现中,使用复杂冗余的参数,而在暴露给客户程序员的接口中,一定要尽可能简洁。

  4. 清晰的文档表述
    这也是我在工作中,最头疼的问题,头疼的不是我不会写,而是在使用其他人提供的库时,没有使用文档,使用起来是痛苦的,所以为了不让这种痛苦发生在其他人身上,我现在从维护一份良好的文档开始。

接口设计想达到的效果

隔离用户操作与底层逻辑

接口的俩种方法

一般来说,有两种方法设计接口类。
第一种是PIMP方法,即Pointer to Implementation,在接口类成员中包含一个指向实现类的指针,这样可以最大限度的做到接口和实现分离的原则。
第二种方法叫Object-Interface方法,它的思想是采用C++的动态功能,实现类继承接口类,功能接口函数定义成虚函数。

先说结论,我们处于自身习惯的原因,选择了Object-Interface方法。

PIMP方法

所谓PImp是非常常见的隐藏真实数据成员的技巧,核心思路就是用另一个类包装了所要隐藏的真实成员,在接口类中保存这个类的指针。

//header complex.h
class ComplexImpl;
class Complex{
public:
    Complex& operator+(const Complex& com );
    Complex& operator-(const Complex& com );
    Complex& operator*(const Complex& com );
    Complex& operator/(const Complex& com );

private:
    ComplexImpl* pimpl_;
};

在接口文件中声明一个ComplexImpl*,然后在另一个头文件compleximpl.h中定义这个类

//header compleximpl.h
class ComplexImpl{
public:
    ComplexImpl& operator+(const ComplexImpl& com );
    ComplexImpl& operator-(const ComplexImpl& com );
    ComplexImpl& operator*(const ComplexImpl& com );
    ComplexImpl& operator/(const ComplexImpl& com );

private:
    double real_;
    double imaginary_;
};

可以发现,这个ComplexImpl的接口基本没有什么变化(其实只是因为这个类功能太简单,在复杂的类里面,是需要很多private的内部函数去抽象出更多实现细节),然后在complex.cpp中,只要

#include "complex.h"
#include "compleximpl.h"

包含了ComplexImpl的实现,那么所有对于Complex的实现都可以通过ComplexImpl这个中介去操作。详细做法百度还有一大堆,就不细说了。

Object-Interface 抽象基类法

一般来说,如果一个接口类对应有若干个实现类,可以采用这种方法。

上面我们讲了plmp方法,我们隐藏掉俩个数据成员,但同时也多出了一个新的数据成员,也就是接口指针,那么有没有方法,连这个指针也不要呢?

这时候就是抽象基类发挥作用的时候了。看代码:

class Complex{
public:
    static std::unique_ptr<Complex> Create();

    virtual Complex& operator+(const Complex& com ) = 0;//纯虚函数,接口成员函数
    virtual Complex& operator-(const Complex& com ) = 0;
    virtual Complex& operator*(const Complex& com ) = 0;
    virtual Complex& operator/(const Complex& com ) = 0;
};

将要暴露出去的接口都设置为纯虚函数,通过 工厂方法Create来获取Complex指针,Create返回的是继承实现了集体功能的内部类;

//Complex类功能的内部实现类
class ComplexImpl : public Complex{
public:
    virtual Complex& operator+(const Complex& com ) override;
    virtual Complex& operator-(const Complex& com ) override;
    virtual Complex& operator*(const Complex& com ) override;
    virtual Complex& operator/(const Complex& com ) override;
private:
    double real_;
    double imaginary_;
}

至于Create函数也很简单:

std::unique_ptr<Complex> Complex::Create()
{
    return std::make_unique<ComplexImpl>();
}

这样,我们完完全全将Complex类的实现细节全部封装隐藏起来了,用户一点都不知道里面的数据结构是什么;

当然,对于Complex这样的类来说,用户是有获取他的实部虚部这样的需求的,也很简单,再加上两个Get方法就可以达到目的。

Object_interface 抽象基类示例代码

  1. 首先,声明一个接口
// circle.h
// 圆的接口类
class Circle {
public:
   virtual ~Circle() {};

   // 接口方法:面积
   virtual double area() = 0;
};
  1. 通过继承的方式实现这个接口
// circle_impl.h
#include "circle.h"
 
// 圆的具体实现类
class CircleImpl : public Circle {
 
private:
	double radius;
public:
	CircleImpl(double radius);
	double area() override;
};
// circle_impl.cpp
#include <cmath>
#include "circle_impl.h"
 
inline double pi() {
	return std::atan(1) * 4;
};
 
CircleImpl::CircleImpl(double _radius) : radius(_radius) {
};
 
double CircleImpl::area() {
	return pi() * radius * radius;
};
  1. 最后,通过管理类创建接口派生类的实例,或者销毁接口派生类的实例:
// circle_manager.h
#include "circle.h"
 
// 圆的创建工厂类
class CircleManager {
public:
    static Circle* create(double radius);     // 创建circle实例
    static void destroy(Circle* circlePtr);   // 销毁circle实例
};
// circle_manager.cpp
#include "circle_manager.h"
#include "circle_impl.h"
 
Circle* CircleManager::create(double radius) {
    Circle* circlePtr = new CircleImpl(radius);
 
    return circlePtr;
};
 
void CircleManager::destroy(Circle* circlePtr) {
    delete circlePtr;
}; 

现在我们接口已经实现完毕了,我们可以把它封装成库,给其他人使用了,这里封装库我们就不多言了。

最后,来看一下使用效果:

// main.cpp
#include <iostream>
#include "circle_manager.h"
#include "circle.h"
 
int main() 
{
    Circle* circlePtr = CircleManager::create(3);
    cout << circlePtr->area() <<endl;
    CircleManager::destroy(circlePtr);
    
    system("pause");
 
    return 0;
}

以上代码只提供给外部circle的接口,circle的实现完全被隐藏了起来,外部将无从知晓,外部使用者只能通过circle管理类生成circle的派生类的实例。外部使用者得到circle派生类的实例后,除了能调用接口暴露的方法area()外,其它什么也做不了,这样就完全达到了使用接口的最终目标。

参考资料

C++中的接口设计准则
C++ 头文件接口设计浅谈
一款优秀的 SDK 接口设计十大原则
C++:如何正确的使用接口类

总结

本篇文章抛砖引玉,自己也是刚刚接触,写完收工,干饭去!

以上是关于工程实践:C++接口设计指北的主要内容,如果未能解决你的问题,请参考以下文章

4种典型限流实践保障应用高可用|云效工程师指北

4种典型限流实践保障应用高可用|云效工程师指北

打通源码,高效定位代码问题|云效工程师指北

有趣免费的开源机器人课程实践指北-2019-

前端 Code Review 指北

C++类设计和实现的十大最佳实践