当前位置: 首页 > 科技观察

C++接口工程实战:有哪些实现方式?

时间:2023-03-11 21:33:46 科技观察

接口在程序开发中经常用到。众所周知,在C++语言层面没有接口的概念,但不代表C++不能实现接口的功能。相反,正是因为C++语言没有提供标准的接口,才使得接口的实际实现方式多种多样。那么C++实现接口有哪些方法,不同的方法分别适用于哪些场景呢?本文分享在C++接口工程实践中的一些探索经验。在程序开发过程中经常使用接口。众所周知,在C++语言层面没有接口的概念,但不代表C++不能实现接口的功能。相反,正是因为C++语言没有提供标准的接口,才使得接口的实际实现方式多种多样。那么C++实现接口有哪些方法,不同的方法分别适用于哪些场景呢?本文分享在C++接口工程实践中的一些探索经验。接口的分类接口按功能可分为调用接口和回调接口:调用接口、一段代码、一个模块、一个程序库、一个服务等(以下简称系统),什么对外提供功能,以接口的形式暴露出来之后,用户只需要关心如何调用接口,而不需要关心具体的实现来使用这些功能。这种由用户调用的接口称为调用接口。调用接口的主要作用是实现对用户的解耦和隐藏。用户只需要关心接口的形式,不需要关心具体的实现。只要保持接口的兼容性,实现的修改或升级就不会被用户感知到。解耦后也方便多人合作开发。接口设计好后,各个模块只通过接口进行交互,各自完成自己的模块。回调接口系统定义了一个接口,由用户实现并注册到系统中。当系统需要通知用户一个异步事件时,会回调用户注册的接口的实现。系统定义了界面的形式,而无需关心界面的实现,而是接受用户的注册,并在适当的时候调用。这种由系统定义,用户实现,系统调用的接口称为回调接口。回调接口的主要作用是异步通知。系统定义了通知接口,并在适当的时候发送通知。用户收到通知并执行相应的操作。用户动作执行完毕后,控制权交还给系统,可以将用户动作交给系统。返回一些数据来判断系统后续的行为。2.调用接口我们以一个Network接口为例,来说明C++中调用接口的定义和实现。示例如下:classNetwork{public:boolsend(constchar*host,uint16_tport,conststd::string&message);}网络接口现在只需要一个发送接口就可以发送消息到指定地址。下面我们使用不同的方法来定义网络接口。虚函数虚函数是定义C++接口最直接的方式。使用虚函数定义Network接口类如下:Network*network);}定义send为纯虚函数,让子类实现,子类不对外暴露,提供静态方法New创建子类对象,并将其作为指向父类Network的指针返回。接口设计一般遵循对象在哪里创建就销毁的原则,所以提供了一个静态的Delete方法来销毁对象。因为对象的析构被封装在接口内部,所以Network接口类不需要虚析构函数。使用虚函数定义接口简单明了,但也有很多缺点:虚函数开销:虚函数调用需要使用虚函数表指针间接调用,运行时可以决定调用哪个函数,不能内联优化在编译和链接期间。实际上调用接口可以在编译时决定调用哪个函数,没有虚函数的动态特性。二进制兼容:由于虚函数是根据索引查询虚函数表调用的,添加虚函数会导致索引发生变化,新接口无法在二进制层面兼容旧接口,同时由于用户可能会继承Network接口类,在末尾添加虚函数也有Risk,所以虚函数接口一旦发布,就很难再修改了。指向实现的指针指向实现的指针是在C++中定义接口的推荐方法。使用指向实现的指针来定义网络接口类,如下所示:Network的实现通过impl指针转发给NetworkImpl,NetworkImpl使用预声明对用户隐藏。接口是通过指向实现的指针来定义的。接口类对象的创建和销毁可由用户自行承担。因此,用户可以选择在栈上创建Network类对象,生命周期自动管理。使用指向实现的指针定义接口具有良好的通用性,用户可以直接创建和销毁接口对象,添加新的接口函数不影响二进制兼容性,便于系统演化。指向实现的指针增加了一层调用。虽然对性能的影响几乎可以忽略不计,但不符合C++的零开销原则。那么问题来了,C++能不能实现零开销的接口呢?当然是下面的隐藏子类来介绍。隐藏子类隐藏子类可以实现零开销的接口,思路很简单。调用接口的目的是解耦,主要是隐藏实现,也就是隐藏接口类的成员变量。如果接口类的成员变量可以移到另一个隐藏的实现类中,则接口类不需要任何成员。变量也达到了隐藏执行的目的。隐藏的子类是隐藏的实现类。使用隐藏子类定义Network接口类如下:~Network();}网络接口类只有成员函数(非虚函数),没有成员变量,构造函数和析构函数都被声明为protected。提供静态方法New创建对象,静态方法Delete销毁对象。New方法的实现创建了一个隐藏子类NetworkImpl的对象,并将其作为指向父类Network的指针返回。NetworkImpl类存储Network类的成员变量,将Network类声明为友元:使用返回父类Network指针,通过将this转换为NetworkImpl指针来访问成员变量:boolNetwork::send(constchar*host,uint16_tport,conststd::string&message){NetworkImpl*impl=(NetworkImpl*)this;//通过impl访问成员变量,实现Network}staticNetwork*New(){returnnewNetworkImpl();}staticvoidDelete(Network*network){delete(NetworkImpl*)network;}使用隐藏子类定义接口也有很好的通用性和Binary兼容性,在不增加任何开销的同时,符合C++的零开销原则。三个回调接口也以Network接口为例,说明C++中回调接口的定义和实现。例子如下:classNetwork{public:classListener{public:voidonReceive(conststd::string&message);}boolsend(constchar*host,uint16_tport,conststd::string&message);voidregisterListener(Listener*listener);}现在Network需要添加接收消息的功能,添加Listener接口类,由用户自己实现,将其对象注册到Network中,当有消息到达时,回调Listener方法的onReceive。Virtualfunctions使用虚函数定义Network接口类如下:listener);}定义onReceive为纯虚函数,由用户继承实现。由于多态性的存在,回调是实现类的方法。使用虚函数定义回调接口简单明了,但也有与在调用接口中使用虚函数相同的缺点:虚函数调用开销大??,二进制兼容性差。函数指针函数指针是C语言的方式。使用函数指针定义网络接口类如下:classNetwork{public:typedefvoid(*OnReceive)(conststd::string&message,void*arg);string&message);voidregisterListener(OnReceivelistener,void*arg);}使用函数指针定义C++回调接口简单高效,但只适用于回调接口只有一个回调函数的情况。如果需要在Listener接口类中添加onConnect、onDisconnect等回调方法,用单一的函数指针是无法实现的。另外,函数指针不符合面向对象的思想,可以换成下面要介绍的std::function。std::functionstd::function提供了可调用对象的抽象,它可以封装具有匹配签名的任意可调用对象。使用std::function定义Network接口类如下:);}std::function可以很好地替代函数指针。加上std::bind,具有很好的通用性,因此广受推崇。但是std::function也只适用于回调接口只有一个回调方法的情况。另外std::function比较重量级,但是使用上面的便利会带来性能损失。有人做过性能对比测试,std::function比普通函数慢6倍左右,比虚函数慢。类成员函数指针的使用更加灵活。使用类成员函数指针定义Network接口类如下:主机,uint16_tport,conststd::string&message);voidregisterListener(Listener*listener,OnReceivemethod);templatevoidregisterListener(Class*listener,void(Class::*method)(conststd::string&message){registerListener((Listener*)listener,(OnReceive)method);}}因为类成员函数指针必须和类对象一起使用,所以Network的注册接口需要同时提供对象指针和成员函数指针,registerListener模板函数是任何类的对象和对应的方法符合签名即可注册,无需继承Listener,与接口类解耦,使用类成员函数指针定义C++回调接口,灵活高效,去可以在不破坏面向对象特性的情况下实现接口类的耦合,可以很好的替代传统的函数指针方式。类成员函数指针也只适用于回调接口中只有一个回调方法的情况。如果有多个回调方法,则需要为每个回调方法提供一个类成员函数指针。那么有没有办法实现与接口类的解耦,适用于多回调方法的场景呢?参考下面介绍的非侵入式界面。四个非侵入式接口Rust中的Trait非常强大。它可以在不修改类代码的情况下在类外实现一个Trait。C++可以实现Rust的Trait的功能吗?还是以Network接口为例,假设Network发送Serialization需要考虑,需要重新设计Network接口。示例如下:定义Serializable接口:classSerializable{public:virtualvoidserialize(std::string&buffer)const=0;};Network接口示例:classNetwork{public:boolsend(constchar*host,uint16_tport,constSerializable&s);}Serializable接口相当于Rust中的Trait,现在所有实现Serializable接口的类的对象都可以通过Network接口发送。那么问题来了,我们是否可以在不修改类定义的情况下实现Serializable接口呢?如果我们想通过Network发送int类型的数据,可以吗?答案是肯定的:classIntSerializable:publicSerializable{public:IntSerializable(constint*i):intThis(i){}IntSerializable(constint&i):intThis(&i){}virtualvoidserialize(std::string&buffer)constoverride{buffer+=std::to_string(*intThis);}private:constint*constintThis;};通过实现Serializable接口的IntSerializable,可以通过Network发送int类型的数据:Network*network=Network::New();inti=1;network->send(ip,port,IntSerializable(i));Rust编译器通过impl关键字记录了每个类实现了哪些Traits,因此编译器可以在赋值时自动将对象转换为对应的Trait类型,但C++编译器不记录这些转换信息,需要手动转换类型。非侵入式接口将类与接口区分开来。类中的数据只有成员变量,不包含虚函数表指针。该类不会引入N个虚函数表指针,因为它实现了N个接口;而接口只有虚函数表指针,不包含数据成员,类和接口之间的类型转换是通过实现类进行的,实现类充当类和接口之间的桥梁。一个类在用作接口时只会引入虚函数表指针,不用作接口时则没有虚函数表指针,更符合C++的零开销原则。【本文为专栏作者《阿里巴巴官方技术》原创稿件,转载请联系原作者】点此查看作者更多好文