ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从工程视角重新审视C++封装、继承与多态

从工程视角重新审视C++封装、继承与多态 接手过一个维护了七八年的老项目一个业务类里塞了四十多个public成员变量某天产品要求把金额从double换成定点数结果整个工程三百多处引用点全炸了改了两天才勉强编译通过。那次之后我才真正理解教科书上轻描淡写的封装两个字背后是以后改起来要不要命的生存问题。C的三大特性——封装、继承、多态几乎所有入门教程都会提但大部分讲的是语法定义很少有人讲清楚它们在实际工程里各自解决什么、代价是什么、什么时候不该用。这篇就结合我带项目、看别人代码、自己踩坑的经历把这三样东西从语法层面拉到工程层面聊一遍尽量让刚学C的朋友能看懂也让写过几年的人能重新审视自己手头的设计。1. 封装从能跑就行到改起来不慌的分界线封装这个词被讲烂了但真正理解它的人不多。大部分人以为封装就是给成员变量加个private再写getter和setter如果只做到这一步其实你只是把裸奔的字段换成了带门的字段维护成本不降反升——因为调用方还得穿透一层函数调用。封装真正要做的事是把变化关进一个盒子里让盒子里怎么折腾外面都不用改。1.1 成员变量裸奔带来的连锁反应先说个具体场景。假设你写一个订单类直接把金额字段暴露出来class Order { public: double amount; int status; };看起来简洁用起来也方便order.amount 99.9;一行搞定。问题出在哪里当某天需求变成金额必须是分为单位的整数或者金额不能为负或者下单后金额不可修改的时候你会发现所有直接给amount赋值的地方都可能出问题。这类代码的可怕之处在于编译器不会帮你报错错误往往等到线上跑出脏数据才暴露。我在实际项目里见过更极端的一个status字段被十几个模块直接修改谁都能改谁都能读到中间状态最后排查一个状态错乱的问题时光找哪些地方改了status就花了大半天。这就是典型的字段裸奔综合症——数据没有归属规则没有出口。封装的第一个价值不是隐藏而是收口。所有对这个字段的读写都必须经过你定义的入口那么校验逻辑、转换逻辑、日志逻辑、变更通知逻辑才有地方安放。1.2 private、protected、public 三种访问级别的实际取舍很多人写代码时对这三个关键字的使用是随意的觉得能编译过就行。其实它们对应三种不同的设计意图访问级别谁能访问设计意图private只有本类这是实现细节子类和外部都不该碰protected本类和子类这是给子类预留的扩展点public所有人这是对外承诺的稳定接口关键在于不要把protected当成稍微开放一点的private随便用。一旦你把一个成员标记为protected就等于对外承诺所有子类都可以依赖它。这个承诺很难收回因为子类可能遍布在各个模块里。我的经验是除非你已经想清楚子类确实需要直接操作这个成员而且未来一段时间不会改它的语义否则一律用private需要给子类的通过public或protected的成员函数提供。1.3 用抽象接口把实现彻底隔离比单个类的封装更高一层的是模块级封装。做法是用一个只包含纯虚函数的抽象接口类作为对外契约具体实现全部藏在实现类里。// 对外只暴露这个头文件 class IUserService { public: virtual ~IUserService() default; virtual bool addUser(const std::string name) 0; virtual int getUserCount() const 0; }; // 工厂函数返回接口指针 std::unique_ptrIUserService createUserService();调用方只能看到IUserService看不到背后用的是内存map还是数据库也看不到用了什么锁。这样带来的好处是实现可以整体替换测试可以注入mock编译依赖也被切断——实现类的头文件改变时调用方不需要重新编译。这是大型项目里控制编译时间的关键手段之一。注意接口类一定要有虚析构函数否则通过基类指针delete派生类对象时派生类的析构函数不会被调用这是内存泄漏的经典来源后面第5节会详细讲。封装的边界怎么定我的判断标准是将来最可能改变的东西一定要封起来。算法会变、存储会变、第三方库会变这些都要封而那些本身就极其稳定的概念比如一个点的x、y坐标包一层getter反而增加噪音。封装不是越厚越好是把该挡的挡住。2. 继承不只是代码复用更是类型关系的表达关于继承新手最容易犯的错是看到两个类有相似的代码就抽个基类出来。这是把继承当成代码复用的工具而它真正的用途是表达类型之间的关系。用错方向的继承带来的耦合比重复代码更难收拾。2.1 public继承的is-a契约public继承表达的是派生类是一种基类。比如Dogpublic继承Animal意思是狗是一种动物。这个语义很重要因为一旦你写了public继承就默认接受了一条规则凡是能用Animal的地方都应该能换成Dog而行为正确。这在设计原则里叫里氏替换原则。举个反例Square继承Rectangle是经典的教学案例。直觉上正方形是矩形所以让它继承。但矩形有宽高可以独立设置的接口正方形一旦继承了它就得处理设置宽度时高度也要跟着变这种特殊情况导致把Square传给需要Rectangle的函数时行为可能不符合预期。问题的根子在于正方形在可独立改变宽高这个语义上并不是一种矩形。所以判断该不该public继承不要问它是不是长得像要问它能不能在不破坏基类契约的前提下替换掉基类。如果答案是不确定那多半不该用继承。2.2 protected和private继承到底在什么时候用这两种继承方式在实际项目里出现得少但用对了能解决问题。protected继承的含义是派生类知道自己是基类的一种实现但对外不承认这层关系。也就是说外部拿到派生类对象没法把它当基类用但派生类内部可以访问基类的protected成员。这种用法非常冷门我几乎没在正经项目里见过必须用它的场景。private继承的含义是基类只是派生类的一个实现细节类似用基类来实现派生类而非派生类是基类的一种。它和成员变量持有基类对象的效果相近但private继承能吃一些空基类优化节省一个指针大小的空间。在追求极致内存的场景下比如高频交易里的订单结构private继承偶尔会被用到用来复用基类的实现同时又不想暴露这层关系。不过坦白说除非你有明确的性能理由或者历史包袱这两种继承方式优先考虑用组合替代代码可读性会好很多。组合的好处是关系写在脸上class Car { private: Engine engine_; }一看就知道车有发动机而不是是发动机。2.3 菱形继承与虚继承的代价多重继承是C比Java多出来的一块能力用得最小心。最典型的坑是菱形继承类A是基类B和C都public继承AD又同时继承B和C。这时候D里会存在两份A的子对象访问A的成员时会产生二义性。class A { public: int value; }; class B : public A {}; class C : public A {}; class D : public B, public C {}; // D里有两份A解决方式是让B和C虚继承Aclass B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {}; // D里只有一份A虚继承能解决二义性但代价是内存布局变得复杂——为了在多个子对象间共享同一个A编译器要引入虚基类指针或偏移表对象大小增加访问成员多一次间接寻址构造顺序也变得更绕。我的建议是多重继承尽量只用于一个主类加若干接口类的模式也就是最多只有一个基类含有数据成员其他都是纯接口。这样既跑不出菱形继承也避免了虚继承的性能开销。3. 多态运行期才决定调谁编译器到底做了些什么多态是三大特性里最魔法的一个。同样一句animal-speak()运行到不同对象时能调不同函数这背后不是编译器在运行时猜而是一套非常明确的内存机制。把这套机制搞明白很多诡异现象对象切片、析构不调用、构造函数里调虚函数失效都能自己解释。3.1 静态绑定和动态绑定的分水岭默认情况下C的函数调用是静态绑定的——编译器在编译期就知道该调哪个函数直接生成一条跳转指令。只有当你通过指针或引用调用虚函数时才会变成动态绑定也就是运行期根据对象的实际类型决定调哪个版本。class Animal { public: void eat() { std::cout animal eat\n; } // 非虚 virtual void speak() { std::cout animal speak\n; } // 虚 }; class Dog : public Animal { public: void eat() { std::cout dog eat\n; } void speak() override { std::cout dog speak\n; } }; Animal* p new Dog(); p-eat(); // 输出 animal eat静态绑定看指针类型 p-speak(); // 输出 dog speak动态绑定看对象实际类型这个例子能解释为什么用值传递对象会导致多态失效——值传递是拷贝构造出来的是一个Animal对象虚函数表指向的是Animal的版本Dog独有的部分被切掉了这就是对象切片。3.2 虚函数表和虚函数指针的内存布局每个含有虚函数的类编译器都会为它生成一张虚函数表vtable表里按顺序存放各个虚函数的地址。每个该类对象的内存布局里开头通常是开头会有一个指向这张表的指针称为vptr。调用p-speak()时实际过程大致是通过p找到对象的vptr通过vptr找到虚函数表再按speak在表里的固定偏移取出函数地址最后跳过去执行。这就是多一次间接寻址的来源。理解这个布局能解释几件事第一有虚函数的对象会多一个指针大小32位系统4字节64位系统8字节。如果你的类里有几百万个实例多出来的内存不容忽视。第二虚函数的调用无法内联至少无法完全内联因为编译期不知道调哪个版本这对性能敏感的热路径有影响。第三fork出来的子进程和vptr没关系但对象通过二进制序列化传到另一台机器再reinterpret_cast回来vptr会失效这是很多人踩过的坑。3.3 纯虚函数与抽象接口的设计取舍纯虚函数写成virtual void foo() 0;含纯虚函数的类叫抽象类不能实例化。它的意义是只定义契约不提供实现强制子类去实现。设计抽象接口时有几个实际取舍接口要不要有数据成员最好不要。接口类的职责是定义行为一旦塞进数据成员派生类的布局就会跟它绑定未来改接口会牵连所有子类。接口粒度多细一个接口只做一件事。我见过把十几个不相关方法塞进一个大接口的代码导致每个实现类都得写一堆空实现维护极痛苦。纯虚函数要不要给默认实现可以给子类可以选择性调用基类实现通过Base::foo()这在不方便修改子类的场景下有用但容易让接口语义模糊谨慎使用。class IShape { public: virtual ~IShape() default; virtual double area() const 0; virtual void draw() const 0; };这种接口配合工厂函数使用是C里做插件化、做依赖倒置最常见的方式。调用方只依赖IShape完全不知道背后是圆还是方新增形状也不需要重新编译调用方。4. 三者协同一个可扩展渲染框架的完整设计前面三节把特性拆开讲了但真正体现它们价值的是三者合起来用。这里用一个简化的图形渲染框架把封装、继承、多态串起来走一遍你会看到每个特性各自负责什么。4.1 用封装划出模块边界框架大致分三层接口层、实现层、调度层。接口层只放抽象类和工厂声明实现层放具体图形调度层负责管理一组图形并按顺序绘制。// shape.h —— 对外唯一暴露的头文件 class IShape { public: virtual ~IShape() default; virtual double area() const 0; virtual void render() const 0; }; std::unique_ptrIShape createCircle(double r); std::unique_ptrIShape createRectangle(double w, double h);接口头文件里没有圆和矩形的定义也没有任何数据成员。这样设计的好处是调用方拿到的是unique_ptrIShape它能做的只有调用接口里声明的方法无法窥探和干预实现细节。将来把圆的实现从手工计算面积换成用数学库调用方毫无感觉。4.2 用继承搭建类型树实现层里圆和矩形各自public继承IShape// circle.cpp class Circle : public IShape { public: explicit Circle(double r) : radius_(r) {} double area() const override { return 3.14159265 * radius_ * radius_; } void render() const override { /* 具体绘制逻辑 */ } private: double radius_; }; std::unique_ptrIShape createCircle(double r) { return std::make_uniqueCircle(r); }注意override关键字一定要加它让编译器帮你检查是不是真的覆盖了基类的虚函数——拼错函数名、参数类型不匹配都会直接报错。早期C没有这个关键字多少以为覆盖了其实没覆盖的bug都是这么来的。4.3 用多态完成运行时分发调度层持有一组IShape统一处理class Renderer { public: void addShape(std::unique_ptrIShape s) { shapes_.push_back(std::move(s)); } double totalArea() const { double sum 0; for (const auto s : shapes_) sum s-area(); // 多态调用 return sum; } void renderAll() const { for (const auto s : shapes_) s-render(); // 多态调用 } private: std::vectorstd::unique_ptrIShape shapes_; };Renderer完全不知道具体是圆还是矩形它只依赖接口。新增一个三角形只要实现IShape并在工厂里加一个创建函数Renderer一行代码都不用改。这就是多态配合封装带来的扩展能力。回到整个设计封装负责接口和实现分离继承负责用类型关系组织实现多态负责运行期按实际类型分发。三者不是并列的三个知识点而是互相依赖的一组机制缺一个这套扩展性就立不住。5. 实战里最容易翻车的几个点语法层面的东西好讲真正让人半夜起来改代码的都是些边角情况。下面这几个坑我几乎在每一个用过继承的项目里都见过至少一次。5.1 对象切片多态悄悄失效前面提过把派生类对象赋值给基类对象时派生类独有的部分会被切掉std::vectorIShape shapes; // 这里存的是值不是指针 shapes.push_back(Circle(1.0)); // Circle被切成IShape编译往往还不报错更隐蔽的是函数参数void process(IShape s); // 值传递进来时已经切了 void process(const IShape s); // 引用传递保留多态一旦发生切片后续调用虚函数全部走基类版本而且行为可能依然是合法的基类的纯虚函数除外那种情况直接编译不过。所以多态对象永远通过指针或引用来使用容器里存unique_ptr或shared_ptr函数参数用引用这是铁律。5.2 基类析构函数忘了加virtual这是最经典也最致命的坑class Base { public: ~Base() { std::cout ~Base\n; } // 非虚 }; class Derived : public Base { public: ~Derived() { std::cout ~Derived\n; } }; Base* p new Derived(); delete p; // 只输出 ~BaseDerived的析构没被调用后果是派生类申请的资源内存、文件句柄、锁全部泄漏。规则很简单只要一个类可能被当作基类通过指针delete它的析构函数就应该是虚的。成本是对象多一个vptr如果它本来没有虚函数的话但换来的安全性完全值得。如果这个类只是做接口那就直接写成virtual ~IShape() default;。5.3 构造函数和析构函数里调虚函数在构造基类部分的时候派生类还没构造完此时对象的vptr指向的是基类的虚函数表所以调用虚函数会走到基类版本而不是派生类版本。析构时反过来同理。class Base { public: Base() { init(); } // 这里调用的是Base::init virtual void init() { std::cout Base init\n; } }; class Derived : public Base { public: void init() override { std::cout Derived init\n; } };构造Derived时会输出Base init很多人第一次遇到会以为是编译器bug。理解了vptr的初始化时机就明白这是有意为之——在基类构造期间派生类的成员还是一片未初始化的内存调派生类版本会访问到垃圾数据。所以构造函数里不要调用虚函数也不要把需要多态行为的事情放到构造里做改成提供一个显式的initialize()方法让对象构造完成后再调。5.4 继承层级过深与为了复用而继承最后一个是设计层面的坑。我见过一个项目的类继承层数到了六层改一个底层行为要往上翻六个文件才知道影响范围新人根本不敢动。继承层级一般控制在三层以内比较健康超过就该考虑用组合或者抽出独立的服务类。判断是不是为了复用而继承有个简单方法如果你在派生类里大量重写基类的方法或者写了很多dynamic_cast来判断实际类型那多半是继承关系用错了真正的is-a关系不应该需要这些补丁。总结成几句实在的建议封装优先保证变化的隔离继承先问是不是is-a多态对象永远用指针或引用持有基类析构记得加virtual构造函数里别碰虚函数继承层级别超过三层。把这几条守住C这三大特性用起来至少不会翻车。至于什么时候该用接口类、什么时候该用模板而不是虚函数那是另一个话题后续可以单独聊。
返回列表