C++ 头文件接口设计浅谈
作者:独钓寒江雪
链接:https://zhuanlan.zhihu.com/p/338227526
对于很多出入门C++ 的程序员来说,大部门新手都是在用别人封装好的库函数,却没有尝试过自己封装一个自己的库提供给别人用。在公司里也见过一些新同事对于库的封装手足无措,不知道怎么将层级抽象化。这里提供一下我自己的见解。
我们知道,C++的三大特性:继承,多态,封装。在抽象一个功能库的时候,就是运用到了这三大核心思路。先说说在C++头文件接口设计中秉承的思路:
隔离用户操作与底层逻辑 这个其实就是要对你的底层代码逻辑做好抽象,尽量不要暴露你的代码逻辑,比如在opencv里面,对图像的操作大部分是通过cv::Mat这个矩阵类来实现的,这个类提供了很多操作图像的接口,使得用户可以不用直接接触像素操作,非常方便。举个简单的例子: class Complex{ public: Complex& operator+(const Complex& com ); Complex& operator-(const Complex& com ); Complex& operator*(const Complex& com ); Complex& operator/(const Complex& com ); private: double real_; double imaginary_; }; 通过这样简单的封装,用户可以直接使用+-*/四种运算符进行复数的运算,而数据成员则是被private隐藏了,用户看不见。这不仅是形式上的需要,更是为了我们程序员的身心健康着想。试想,一旦我们在接口中暴露了数据成员,那么一定有用户做出一些超出你设计意图之外的操作,为了防止这些骚操作不把程序crash掉,你要增加很多的异常处理。更有可能的是有些异常是你预想不到的。 那么这样是否就完美了呢?显然不是。如果把上述代码作为一个接口文件发布出去,用户依然能清清楚楚看到你的private成员,于是你就“暴露”了你的实现。我们要把接口的用户当成十恶不赦的蠢货,就要把成员再次隐藏起来。这时候就可以用到两种处理方式 1)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这个中介去操作。详细做法百度还有一大堆,就不细说了。 2)抽象基类 虽然使用了pimp手法,我们隐藏掉了复数的两个成员,但是头文件依然暴露出了新的一个ComplexImpl*指针,那有没有办法连这个指针也不要呢? 这时候就是抽象基类发挥作用的时候了。看代码: 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方法就可以达到目的。 2.减少编译依赖,简化参数结构 减少编译依赖,一言蔽之,就是不要再头文件里include太多其他头文件,尽可能使用指针或引用来代替。 有些接口需要用户设置的参数,尽量傻瓜化,不必寻求这些参数结构也可以在内部实现中通用。 就比如说,一个渲染字体的接口,如果内部使用到了opencv的一些方法,用户层应该怎么设置参数呢? struct FontConfig{ int line_with; int font_style; int scale; //比重因子 int r; int g; int b; double weight; //权重 } void Render(const FontConfig& config) //内部实现 { cv::Scaler color(config.r, config.g, config.b); cv::putText(...color); // ... } 类似这种代码,其内部实现需要的结构是 cv::Scaler 这个结构,但是我们不能在接口文件中出现,一旦出现了,那也就毫无封装可言,你必须在接口里包含opencv的一堆头文件才能保证编译通过。因此适当的转换是有用且必要的。
作者:独钓寒江雪
链接:https://zhuanlan.zhihu.com/p/392247348
链接:https://zhuanlan.zhihu.com/p/392247348
class Log { static std::string getTime(); static void LogI(const char*fmt, ...) { va_list args; std::string logstr; logstr.resize(MAX_LOG); char *buffer = const_cast<char*>(logstr.data()); //就在同一块内存上继续写,开两块内存是巨大的浪费 int writtenBytes = snprintf(buffer, MAX_LOG, "[%c:%s:TAG] ", level, getTime().c_str()); buffer += writtenBytes; writtenBytes += vsnprintf(buffer, MAX_LOG - writtenBytes, fmt, args); if(writtenBytes < MAC_LOG) { //特别注意这里,vsnprintf返回的是 应该全部写入的字节数 logstr.resize(writtenBytes); } else { logstr.resize(writtenBytes); buffer = const_cast<char*>(logstr.data()); //亿点点小细节,经历了扩容之后,buffer的值可能会变化 int n = snprintf(buffer, writtenBytes, "[I:%s:TAG] %s", getTime().c_str(), buffer); vsnprintf(buffer+n, writtenBytes, fmt, args); } int threadId = va_arg(args, int); if (log_level_ <= INFO) { //调整一下判断方式,先用掉这块内存,又是亿点点小细节 printf("%s", logstr.c_str()); } //把此次调用移到最后,保证std::move()不会出错 //如果像一开始的顺序,CacheLog函数里调了move了,那么printf用到的内存就悬空了 CacheLog(logstr, threadId); va_end(args); } static void CacheLog(const std::string &log, int threadId) { //用传引用 //emplace_back + std::move 实现零拷贝 s_log_cache_list_[threadId].emplace_back(std::move(log)); } static std::map<int, std::vector<std::string> >s_log_cache_list_; static int log_level_; }
作者:独钓寒江雪
链接:https://zhuanlan.zhihu.com/p/392217033
在跨平台代码中,用宏来隔离代码实现是家常便饭了,但还是发现身边同学经常搞不清这些用法,以至弄出不少编译问题甚至逻辑错误,比如: #ifdef __OS_MAC__ || TARGET_OS_LINUX // 如果定义了这两个宏其中一个 // so something #endif 或者: #define OPEN_AUTH 0 //授权开关 //... #ifdef OPEN_AUTH //是否需要授权 // do A ... #else // do B ... #endif 你猜会进入哪个分支?这两种错误最常见,究其原因,都在于没有搞清楚这些条件包含语句的后面到底是表达式还是标识符,具体语法如下语法 #if 表达式 #ifdef 标识符 #ifndef 标识符 #elif 表达式 #else #endif 注意:#ifdef, #ifndef 检查标识符是否被定义为宏名 这就是”标识符“的意思,这两个预处理条件用来判断这个宏是否被定义,而不是宏的值。第二个例子的错误就在于,#ifdef OPEN_AUTH 是判断OPEN_AUTH这个宏是否被定义,但从上下文看, OPEN_AUTH被定义为0,语义上就是要关闭授权,然而这样判断得到的结果为true。(这种写法很恶心,不要用宏的值来判断)2.#if, #elif 后面是常量表达式 第一个例子中试图串联多个条件,但却使用了 #ifdef 这种写法,却提供了一个表达式,明显不符合语法。结果就是预处理器只看到了第一个宏,忽略了||后的语句,甚至在一些编译器上,直接在编译过程报错。Solution这些预处理语句的使用,无非就是 与、或、非三种用途与 ,判断多个宏是否同时定义#if defined(WIN32) && defined(__DEBUG__) && defined(MSVC) //... #endif 2.或,判断其中一个宏被定义#if defined(_AIX) || defined(__NOVELL_LIBC__) || defined(__NetBSD__) || \ (defined(__MSVC__) && (__MSVC__ > 1900)) #endif 3.非,判断宏没有定义#if !defined(OS_MAC) && !defined(OS_LINUX) && !defined(DEBUG) #endif 基础用法就是这些,其他复杂的组合,只要抓住#if 后是表达式这个关键,即可举一反三写出更复杂的判断条件。再说明一点,不建议像用例二那样,使用宏的值作为条件判断。使用值来判断,会出现 success = 0 还是 1这种狗血问题。多用#if 语句, 少用#ifdef,扩展性更好
================= End