资讯动态

从封装到多态:现代C++面向对象的实战设计指南

发布时间:2026/10/18 10:54:56 来源:尧图企业网站定制
我特别理解那种处境——C的面向对象理论学完一遍书上的例子也都能看懂可真要自己动手设计一个类的时候突然就卡住了这个成员到底该不该私有析构函数什么时候必须写成虚函数拷贝构造函数写了又好像跟没写一样不是说学会了面向对象三大特性就能写代码吗怎么一碰实际项目全是问题这篇文章我想换个讲法。不按教科书顺序把封装、继承、多态一个个摆出来而是从真实项目里这些概念到底在解决什么问题出发把它们串成一条线。你会发现所谓面向对象的核心归根到底就是两件事怎么把数据和操作数据的东西绑在一起封装怎么让不同对象之间用统一的方式协作多态。继承只是实现协作的一种手段还经常被用错。适合谁来读学过C语法或者用过其他面向对象语言、现在想认真把C面向对象这块打通的人。如果你是想搞懂虚函数机制、构造函数和资源管理、接口设计这些实际操作层面的问题那这篇内容正好对胃口。1. 从一个能跑的类开始封装到底保护了什么很多人对封装的理解停留在把数据设为private外面就不能直接改了。这句话没错但它只说了手段没说目的。封装真正的价值不是防贼而是给你的类留下一块可控的演化空间。1.1 没有封装的时代有多痛苦先看一个反面例子。假设你在写一个简单的账户系统最初可能就这么干struct Account { std::string owner; double balance; }; void deposit(Account acc, double amount) { acc.balance amount; }代码完全能跑。但项目一长大问题就来了有一天业务要求账户余额不允许超过十万你得在全工程里搜有多少处代码直接写了acc.balance每一处都检查一遍逻辑。更麻烦的是有些地方直接给余额塞了个负数进去而你没有地方统一拦截。再往后需求变了余额不想用double改成用long long存分。整个工程凡是碰过balance的地方都可能要改。数据和行为是拆开散的牵一发而动全身。1.2 封装加进来之后发生了什么把数据收进private对外只暴露明确的方法class Account { public: Account(std::string owner, double initial) : owner_(std::move(owner)), balance_(initial) { if (initial 0) { throw std::invalid_argument(余额不能为负数); } } void deposit(double amount) { if (amount 0) { throw std::invalid_argument(存款金额必须为正); } if (balance_ amount kMaxBalance) { throw std::runtime_error(超出单账户余额上限); } balance_ amount; } double getBalance() const { return balance_; } const std::string getOwner() const { return owner_; } private: static constexpr double kMaxBalance 100000.0; std::string owner_; double balance_; };看到差别没有我故意写了getBalance()但没写setBalance()。外面的人只能通过deposit()这一个入口改余额所有检查都集中在入口处。以后想加每月只能存取五次这种规则只改这一个方法就够了不用满世界找代码。1.3 封装的第一条实操判断标准判断封装做得好不好不看private有多少而看对外暴露的方法是否足够表达这个类的行为意图。好的封装类的接口读起来像一个任务清单deposit()、withdraw()、getBalance()。差的封装读起来像一个数据仓库setXXX()、getXXX()、getXXX2()。前者在描述这个类能帮你做什么后者只是在把内部数据仓库做仓库管理员。这也是为什么我一直建议所有成员变量一律private起步对外接口尽量给行为而不是给数据。不是每个字段都需要一个getter更不是每个字段都需要setter。你刚写完一个类觉得这个getter以后可能用得上大概率以后也用不上等真的需要时再写成本低得很。封装还有一个被忽略的好处它是实现约束的载体。类的每个方法都可以假定成员变量一定处于合法状态因为这世界只有两条路能改它们——要么通过构造函数建立初始状态要么通过成员函数流转状态。跳出类的范围审查状态合法性那是调用方的责任不需要你操心了。2. 构造函数与析构函数资源所有权交接的战场构造函数是哪个面试里都会考的东西但很多人对它的理解停留在给成员变量赋初值。在我看来构造函数承担着比赋初值重要得多的责任建立类不变量并获取资源所有权。前面说的余额不能负数金额不能超出上限这些约束必须在构造函数里先建立起来否则后面所有方法都会在假设上出现问题。2.1 没有构造函数时对象会处于什么状态先理解一下C默认会给你什么。如果你写了这样一个类class Logger { public: void log(const std::string msg); private: std::ofstream file_; };在 C 里成员变量file_会自动调用ofstream的默认构造函数完成初始化哪怕你没写构造函数。真正麻烦的是那些没有默认构造函数的成员比如std::mutex就没有默认构造函数你必须显式初始化它。很多新手踩的首要问题是以为我没写构造函数对象就不需要构造然后发现某个成员变量是莫名奇妙的值。这是个误区凡是类类型的成员不管你有没有写构造函数它自己都会构造。内置类型int、double、裸指针如果不初始化才是未知值。所以规则很简单如果类里有内置类型成员就别依赖默认初始化。自己写构造函数把它们明确设成合法初值——哪怕只是int count_ 0;这种就地初始化。2.2 析构函数和RAII一种把所有事情安排好的思路析构函数的核心价值在于析构时机确定当对象的生命周期结束时析构函数必然被执行。这看似简单实际上成了一个非常有用的机制——资源清理和异常安全。举个例子。你有一段代码要操作一个文件std::ofstream file(output.txt); if (!file.is_open()) { return; } // ... 各种处理 ... file.close(); // 如果你忘了这一步文件会在ofstream析构时自动关闭就算你忘了写file.close()ofstream的析构函数也会自动关闭文件。这就是把资源获取和释放绑定在对象生命周期上统一叫RAIIResource Acquisition Is Initialization资源获取即初始化。这个命名不太直观我更愿意叫生命周期绑定资源技术。后台处理数据库连接时这类问题最为常见class DbConnection { public: explicit DbConnection(const std::string connStr) : conn_(std::make_sharedDbHandle(connect(connStr))) {} ~DbConnection() { if (conn_) { close(conn_); } } // 禁止拷贝只允许移动 DbConnection(const DbConnection) delete; DbConnection operator(const DbConnection) delete; DbConnection(DbConnection other) noexcept : conn_(std::move(other.conn_)) { other.conn_ nullptr; } DbConnection operator(DbConnection other) noexcept { if (this ! other) { conn_ std::move(other.conn_); other.conn_ nullptr; } return *this; } void execute(const std::string sql) { ... } private: std::shared_ptrDbHandle conn_; };地址资源清理的问题解决了连接不会忘关异常发生时连接也一样会释放。但这类代码的价值远不止不会忘它把异常安全做到位了——因为析构函数一定会执行哪怕中间抛了异常数据库连接也不会泄漏。2.3 构造函数里的异常初始化一半了怎么办这是很多实际项目中最容易被问的问题。构造函数里如果抛出异常这个对象的成员函数不会被调用但已经构造好的成员变量会被依次析构资源不会泄漏。这点C保证得很好你甚至不需要在构造函数里写try-catch来清理已经分配的资源——那是裸指针时代的技巧了。比如class ResourceHolder { public: ResourceHolder(const std::string filePath) : file_(filePath), buf_(new char[1024]) { if (!file_.is_open()) { throw std::runtime_error(文件打不开); } } ~ResourceHolder() { delete[] buf_; } private: std::ofstream file_; char* buf_; };如果构造过程中file_打开失败抛了异常成员buf_也已经分配了内存但析构函数并不会执行因为对象本身没构造成功。buf_就泄漏了。要处理这种情况得自己管理但既然buf_是裸指针就只能手写智能指针——实际上buf_换成std::vectorchar就完全不用纠结这个问题。结论构造函数正确写法是从一开始就不用裸指针管理资源用vector、string、unique_ptr这些自带RAII语义的标准组件。构造函数越简单越不需要防御式清理代码。3. 拷贝与移动同一个类为什么会有两种复制命运本章聊聊C里最容易在细节上卡人的地方——拷贝和移动。这个坑有多深我自己见过的实际项目里遇到异常或性能问题的代码有相当一部分根因都在拷贝语义上。3.1 拷贝构造、拷贝赋值、移动构造、移动赋值四兄弟一次性理清先看代码class Buffer { public: Buffer(size_t size) : size_(size), data_(new char[size]) {} // 拷贝构造用一个已有的对象构造新对象 Buffer(const Buffer other) : size_(other.size_), data_(new char[other.size_]) { std::copy(other.data_, other.data_ size_, data_); } // 拷贝赋值已存在的对象被另一个对象覆盖 Buffer operator(const Buffer other) { if (this ! other) { delete[] data_; size_ other.size_; data_ new char[size_]; std::copy(other.data_, other.data_ size_, data_); } return *this; } // 移动构造把other的资源搬到新对象other变成空壳 Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; } // 移动赋值把other的资源搬给已存在的对象 Buffer operator(Buffer other) noexcept { if (this ! other) { delete[] data_; size_ other.size_; data_ other.data_; other.size_ 0; other.data_ nullptr; } return *this; } ~Buffer() { delete[] data_; } private: size_t size_; char* data_; };这四者的区别我总结成一个表格场景调用的函数核心行为Buffer b1 b2;从已有对象初始化新对象拷贝构造深拷贝数据Buffer b1; b1 b2;对象已存在覆盖拷贝赋值释放旧资源深拷贝数据Buffer b1 std::move(b2);临时对象初始化新对象移动构造转移资源指针把b2置空Buffer b1; b1 std::move(b2);临时对象覆盖已有对象移动赋值释放旧资源转移指针置空b2什么时候发生移动要么你显式写了std::move()要么表达式本身就是右值比如函数返回临时对象。理解了这一点很多不听话的行为就说得通了。3.2 深浅拷贝裸指针出现的地方默认拷贝就是灾难再设想你已经写完了上面的Buffer类但故意不写拷贝构造函数和拷贝赋值运算符。编译器会帮你生成一个默认拷贝——逐成员拷贝shallow copy。对内置类型它是值拷贝对裸指针它就是拷贝指针本身。于是Buffer a(1024); Buffer b a; // b.data_ 和 a.data_ 指向同一块内存两个对象析构时同一块内存被delete两次。程序不是崩溃就是内存被破坏得一塌糊涂。这就是经典的浅拷贝导致双delete问题。各类书籍都会告诉你写了析构函数通常就应该写拷贝构造和拷贝赋值因为你一旦接管了资源管理编译器默认的逐成员拷贝方式已经不适用于裸指针了。编译器没法知道你那个指针是资源的唯一入口还是指向别人资源的借用指针它只能照搬结果就是复制了地址而不是内容。3.3 三/五法则一条规则就能避开一半的内存问题这条规则我用得特别狠简单好记又很管用。只要你有自定义析构函数、拷贝构造、拷贝赋值中的一个那么另外两个基本也必须有——因为析构函数是资源所有权的信号。有了析构函数就一定自己管理资源默认的拷贝方式大概率是不对的。写成清单就是如果你写了析构函数拷走拷贝构造函数和拷贝赋值运算符如果你想禁止拷贝比如管理互斥锁、数据库连接把拷贝构造和拷贝赋值声明为 delete如果你希望类可以高效传递比如大容器、字符串就把移动构造和移动赋值写成 default前提是你能保证成员都能正确移动或者自己实现。更好的消息是如果你用std::vector、std::string、std::unique_ptr这类组件管理资源那么你甚至不需要手写析构函数。默认析构函数会自动调用成员的析构资源就释放了。这就是为什么我强烈建议你的类里尽量不要出现裸指针成员。裸指针一多三/五法则就全套到身上了。3.4 移动语义真正解决的问题C11引入移动语义主要想解决两类问题。第一是性能问题旧标准里return一个含有大量数据的对象会触发深拷贝被拷出来的临时对象效率低下。有了移动构造可以把指针搬走拷贝100MB的vector瞬间变成拷贝几个指针。第二是只移型别对象的问题像std::unique_ptr、std::thread、std::fstream这类对象拷贝在语义上就不允许同一份资源不可能有两个独占所有者但它们确实需要从一个地方传递到另一个地方。移动语义给了它们出路资源所有权可以转移但不会复制。什么时候该给类写移动操作我的经验是凡是管理了独占资源、并且需要放进容器或按值传参返回的类都应该支持移动。而移动操作写好后函数参数传值、返回大对象这些场景就会自动走移动路径不需要到处写std::move()那是过度操作。4. 继承与虚函数设计接口时的关键抉择继承可能是面向对象里被误解最深的概念。复用代码确实是很多人用继承的第一念头但这种出发点往往会导致设计越来越僵化。我后来慢慢意识到继承在C里的正确定位更偏向接口设计而不是代码复用。4.1 虚函数机制的底层真相先简单拆一下机制。当你给基类函数加上virtual编译器会为这个类生成一张虚函数表vtable存放每个虚函数的地址。每个含有虚函数的对象开头会藏一个虚表指针vptr。调用虚函数时程序先通过vptr找到vtable再从里面取出真正应该调用的函数地址。这就是动态绑定的本质调用哪个函数是在运行时根据对象真实类型决定的。没有virtual的普通成员函数在编译期就确定调用谁这叫静态绑定。表格对比如下特征普通成员函数虚函数绑定时机编译期运行期通过vtable性能开销几乎为零一次间接跳转可被CPU分支预测吸收多态能力无支持基类指针/引用调用派生类重写逻辑适用场景不需要扩展的固定行为允许派生类改变行为4.2 为什么基类析构函数几乎总是virtual先看这个经典问题class Base { public: ~Base() { std::cout Base destructor\n; } }; class Derived : public Base { public: ~Derived() { std::cout Derived destructor\n; } }; Base* p new Derived(); delete p; // 只调用Base的析构Derived的析构不被调用如果基类析构函数不是虚函数delete p只会调用Base::~Base()Derived的析构逻辑比如释放它自己的资源彻底被跳过资源泄漏还是轻的如果Derived里有申请的内存和锁基本就是双delete或死锁问题。解决方式也很简单基类析构函数声明为virtual。这样delete p就能沿着vtable动态找到Derived的析构函数执行完派生类析构逻辑后再执行基类析构全程安全。经验教训凡是设计出来要被继承的类析构函数一律virtual。不想被继承那就把构造函数也禁了或者用final明确封死。半吊子的设计最坑人——允许别人继承却不给虚析构就跟埋雷一样。4.3 纯虚函数与抽象基类把函数声明成纯虚用 0对应做成抽象基类。这玩意儿的作用不是给你写了一半的类当摆设而是定义一套接口契约。class IShape { public: virtual ~IShape() default; virtual double area() const 0; virtual void draw() const 0; };IShape不能实例化——它压根没有面积和绘制逻辑。但它是一个约定任何继承它的类必须实现area()和draw()否则你都无法通过编译。这就是接口或者说抽象基类的威力多态协作的边界在编译期就被固定住了。模板编程里也有类似概念但C的模板多态编译期多态和这里说的运行期多态是两条路线。模板在编译期生成具体代码效率更高但类型必须明确虚函数在运行期分发灵活但多一层间接。项目里两种都会用到关键看你在哪个维度做抽象。4.4 自己设计继承体系时的自检清单这么多年下来我给自己写了个清单每次设计继承体系都要过一遍这个基类真的有必要存在吗还是我只是想把两个类的公共函数放一起如果两个派生类的area()实现完全不同那基类里就不应该有任何通用实现。基类析构函数是不是已经virtual了我不想被继承的类有没有用final封死派生类重写函数时加没加override加了之后基类虚函数签名一改编译器就报错防手滑神器我是不是只是在传值返回基类类型结果把派生类切片了第一个问题尤其值得多想。Dog继承Animal看起来天经地义但实际业务里大概率用不上多态——你不会有一个std::vectorAnimal*来装一堆狗和猫。而没有多态的需求继承只是在制造耦合。继承只有在你需要通过基类接口操作多种派生类对象的时候才有意义这个前提不成立就别继承。5. 组合优于继承设计层面的取舍从前有一段时间我看什么都想用继承抽象一下。后来被现实教育了几回每加一层继承代码的可读性、可测试性和重构的灵活性都在掉。相比之下组合——把一个类的对象嵌到另一个类里——往往更直白、更灵活。5.1 IS-A关系和HAS-A关系继承表达的是 IS-A是一个Derived是一种Base。组合表达的是 HAS-A有一个Car拥有一台Engine。// 继承式写法 class TcpServer : public NetworkService { ... }; // 组合式写法 class TcpServer { public: void start() { network_.start(); } void send(const Message m) { codec_.encode(m); network_.send(codec_.data()); } private: NetworkClient network_; MessageCodec codec_; };组合的好处是TcpServer和NetworkClient之间没有继承关系不需要满足Liskov替换原则NetworkClient换个实现比如从TCP换成UDP也不需要改TcpServer的对外接口。想换一个组件直接换成员类型就行牵连面要小得多。5.2 什么时候可以理直气壮地用继承我的判断标准很干你需要把不同类型对象放进同一个容器用基类指针/引用统一操作它们这些类型在业务语义上确实是属于/是一种关系而且基类的不变量比如余额非负能被所有派生类维持你需要让用户代码在编译期看不到具体类型只依赖抽象接口也就是做接口抽象。以上几条至少满足两条才考虑继承。如果只是为了省几个重复函数组合加自由函数就够了。5.3 经典反面案例一个被继承毁掉的业务模型假设要做支付系统你可能会自然地写一个Payment基类然后AlipayPayment、WechatPayment都继承它。开始没问题但突然有一天这两种支付的退款逻辑完全不一样了而基类里已经有一份共同的退款流程。于是你开始往基类里加virtual方法让不同的派生在各自方向上重写基类里堆满了被重写的空函数。又过几次迭代基类变成了一堆什么都不是的接口糊墙派生类之间关系纠缠不清。用组合就简单了让每种支付方式各自实现一个RefundStrategyPayment类持有支付策略和退款策略的组合。每种策略自己维护自己的状态互不干扰。这就是我经常说的继承关系是纵向的每加一层就是强化耦合而组合关系是横向的组件之间各自独立替换起来非常容易。设计上优先选择横向扩展不要轻易把体系往深了做。6. 现代C对传统面向对象的重塑说实话现在写C和十年前已经完全是两种风格了。C11以来移动语义、智能指针、lambda、std::function这些工具进来之后传统的类继承打天下的思路已经变了很多。很多人学完面向对象后代码里还在用最原始的new/delete和到处继承这可不对——现代C给了你更好的选择善用这些选择代码质量和效率都有全面提升。6.1 所有权语义从手动管理到智能指针传统面向对象的资源管理靠的是谁new谁delete这种人为约定出错率很高。现代C引进了std::unique_ptr和std::shared_ptr把所有权表达在类型系统里std::unique_ptrT独占所有权不可拷贝只能移动std::shared_ptrT共享所有权引用计数最后一个释放时销毁对象std::weak_ptrT不增加引用计数用于打破循环引用或者观察shared_ptr管理的对象是否还活着。这段对比很有用// 老式写法 Widget* w createWidget(); use(w); delete w; // 现代写法 std::unique_ptrWidget w(createWidget()); use(w.get()); // 不用delete析构自动执行工厂函数也经常这样设计std::unique_ptrWidget data std::make_uniqueWidget(params);一旦所有权边界用类型表达清楚了这资源到底谁负责释放的问题从运行期靠人脑记忆变成了编译期靠类型保证这件事对整个项目影响很大。6.2 lambda与std::function多态的一种轻量替代传统多态需要设计接口、定义类、继承、重写一整套流程。有时候你其实只是想在回调里做点不一样的事这时候lambda往往更轻便// 传统接口设计 class IEventHandler { public: virtual void onEvent(const Event e) 0; }; class MyHandler : public IEventHandler { public: void onEvent(const Event e) override { // 处理逻辑... } }; eventDispatcher.registerHandler(std::make_uniqueMyHandler()); // 现代写法 eventDispatcher.registerHandler([](const Event e) { // 处理逻辑... });少了两个类和一个继承关系逻辑还能内联在一起读代码的人不需要跳来跳去找实现。配合std::function做类型擦除甚至可以把lambda存下来以后调用。C模板算另一种结构多态——只要类型支持某些操作比如有area()成员函数就能直接使用不需要从某个基类继承。编译期确定调用对象没有虚函数开销也能支持完全无关的类。这就是所谓的鸭子类型。所以说面向对象思维本身没有过时但表达这个思维的工具早已不局限于抽象基类继承这一个模式了。6.3 std::optional与std::variant比继承更轻的同一事物多种形态有一种多态场景很多人用继承解一个函数可能返回多种不同类型的值。以前就只能定义一个基类再让几种类型继承它返回基类指针或者std::unique_ptrBase。但现在有更轻的选择// 一个可能没有结果的函数 std::optionalRecord findRecord(int id); // 一个可能由多个不同类型构成的结果 std::variantCircle, Rectangle, Triangle shape createRandomShape();std::variant本质上是一个类型安全的联合体它把几种不同类型的事情放进了同一个类型用std::visit来统一处理。这比继承体系要简单也没有动态分配。什么时候用继承、什么时候用variant我的经验是领域模型里类型的数量很可能不断增加、且每个类型都有特有行为时用继承类型的组合相对有限、且处理和类型无关时用variant更清爽。从这个角度看现代C并不是要消灭面向对象而是给了面向对象更丰富的工具箱。封装、接口、资源生命周期这些核心思想仍然在只是你实现它们的路径多了好几条。7. 设计原则从写对到写不坏前面讲了具体的技术点最后想谈几个贯穿所有场景的设计原则。它们不是语法但比语法更能决定你的类设计是否会慢慢腐化。7.1 单一职责原则类不应该同时干两件事一个类如果它的职责既是存取数据又是序列化JSON又是打印日志那么任何一处变化都要连带改它。更好的做法是拆成三个类分别管正确的事。这条原则要落到C里可以这样实践每写一个方法问一句这个行为是这个类本来就应该负责的吗如果不该就挪走。数据模型和IO操作分离是常见场景// 不理想 class Order { public: void saveToDatabase(); std::string toJson(); void printReceipt(); // 数据成员... }; // 改进方向 struct OrderData { /* 纯数据 */ }; class OrderSerializer { public: static std::string toJson(const OrderData); }; class OrderRepository { public: void save(const OrderData); };类越小接口越明确组合它的路径就越多。7.2 接口隔离原则不要逼调用方依赖他们不需要的方法接口设计失败的经典症状一堆类继承一个万能基类但每个派生类只用得到其中两个方法其余方法全是空实现或者抛异常。这种设计大家在改基类的时候很痛苦——你只想加一个方法结果所有派生类都被迫改了一遍。C里的解决方式不搞巨型接口而是把接口拆小。用多个小的抽象类每个只约定一个维度的能力class IWorker { public: virtual ~IWorker() default; virtual void work() 0; }; class IReporter { public: virtual ~IReporter() default; virtual void report() 0; }; // 需要两个能力的类同时实现两个接口 class SomeWorker : public IWorker, public IReporter { public: void work() override; void report() override; };调用方拿到的类型就是纸面写的那样小而确切不用为了不需要的能力买单。7.3 依赖倒置原则让高层依赖抽象而不是依赖细节传统做法是上层类直接依赖下层具体类于是下层一变化上层就要跟着改。依赖倒置的意思是让上下层都依赖抽象接口而不是直接依赖具体实现。具体到C就是多依赖接口或者std::function少直接依赖具体类型的对象。class NotificationService { public: explicit NotificationService(std::functionvoid(const std::string) sender) : sender_(std::move(sender)) {} void notify(const std::string msg) { sender_(msg); } private: std::functionvoid(const std::string) sender_; };在这个设计里NotificationService根本不认识邮件实现短信实现这回事只依赖一个函数对象。将来要加微信推送直接创建一个新lambda就行不需要动NotificationService一行代码。控制流和依赖方向反过来了高层不再被底层绑架。7.4 面向对象与性能什么时候该放开手脚最后聊一个务实话题。很多人担心虚函数、智能指针影响性能。我的看法很明确先把代码写正确、写好维护再回来找热点。虚函数调用在现代CPU分支预测面前几乎是可以接受的shared_ptr的引用计数开销也不是大问题。真正能拖垮性能的往往是你在不该深拷贝的地方深拷贝了、该用移动却用了拷贝、在循环里频繁new/delete——这些都是设计问题不是面向对象本身的锅。真到性能热点再优化也不迟确定属于热点可以先试试改用模板或std::variant消除动态分配不行再考虑手动管理内存方案。但无论如何先保证设计是清晰的、数据流是明确的。一个藏着一堆裸指针和到处复制大对象的类不管多偏向性能优先最后都会让维护成本和出bug概率同时上升。写C面向对象最好的状态就是脑子里有一套清晰的资源所有权观念知道自己的数据边界在哪里能控制类的生命周期和对外承诺再用继承和虚函数只做真正需要多态的事情。绕过那些形式化的条条框框直击问题本质你会发现这些概念根本不是必须背下来考高分的知识点而是一套帮你把复杂系统组织得井井有条的思维方法。我自己的体会是面向对象这东西写个两年左右才能慢慢感觉到设计感。一开始写出来的类永远是过度设计或者设计不足的但都不怕怕的是不再迭代。每次重构时想想封装边界是不是变模糊了、继承是多了还是该拆了、资源所有权是不是清晰——一点点打磨代码质量是能稳定向上走的。最后补一个小技巧如果环境支持C20把std::span、std::string_view这些视图类型也纳入你的类接口设计里传参传只读视图数据所有权留在调用方整个类会变得意外地简洁。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑