Skip to content

Latest commit

 

History

History
309 lines (208 loc) · 26 KB

develop-design.md

File metadata and controls

309 lines (208 loc) · 26 KB

C++ 程序设计的一些想法

作者:金祖鑫,邮箱 [email protected]

单位:北京科学智能研究院(AISI)

最后更新日期:2024 年 3 月 5 日

欢迎提出建议,本文将持续更新

一、内存管理

C++ 语言的设计理念之一是给开发者最大程度的信任与自由。这也意味着很多因疏忽导致的漏洞并不会在语法层面被阻止,因而也不会被编译器过滤。在一个复杂的程序中,内存管理是最常见的问题之一。比如,以下情况在实践中时有发生:

  • 通过 new 为某指针申请了内存,在该指针生命周期结束前既没有其他指针接管,也没有 delete【内存泄漏】
  • A, B 两指针指向同一对象,A delete 后 B 继续访问【悬空指针(dangling pointer)】

这些问题无法被编译器过滤,很多时候也不会导致程序运行停止。然而,如果一段会泄漏内存的代码被反复执行,泄漏的内存量积累到一定程度就可能导致内存不足进而程序中断;被 delete 的内存块在被操作系统回收前依然可以访问【回收前访问是未定义行为 (undefind behavior);回收后访问会 segmentation fault】:这片内存既可能保留原有数据不变,也可能已被挪作他用,写上了新的数据。这些问题往往构成巨大隐患。此外,还有一些会导致程序立即中断的内存问题,比如重复 delete、访问 nullptr 等。这些问题虽容易发现,也会耗费开发者不少精力。

C++ 社区与标准委员会很早就意识到了这些问题。出于“信任与自由”这一理念以及对过往代码的兼容性,语言并没有往“编译期确保内存安全”这一方向发展。此外,出于“零开销”(zero overhead)原则,也没有引入垃圾回收机制。时至今日,内存问题依然困扰大量 C++ 开发者。不过,自 Stroustrup 提出"Resource Acquisition Is Initiallization" (RAII) 的资源管理理念以来,截至 C++11,借由智能指针等工具的引入,整个语言已具备了实现方便、可靠的内存管理的条件。简而言之,C++ 给愿意自觉遵守 RAII 的开发者足够的工具以便捷、安全地管理内存,同时也不阻止开发者以完全自由、“后果自负”的方式进行开发。

1. RAII 原则

RAII 中的 R 并不单指内存,也可以指文件、线程或其他广义的资源。字面上 RAII 仅指一个对象的初始化需要与其资源获取绑定,但其完整的含义其实是“对象持有的资源有效期与对象的生命周期完全绑定”。以内存为例,这种“绑定”并不是指类的设计者给使用者提供一个用来释放内存的 public 函数,而是指类要做到从设计上就保证无论使用者如何使用,对象管理的内存有效期都不超过其生命周期。【异常处理不在本文的考虑范围内】

最常见的违背 RAII 原则的对象是裸指针。裸指针作为一个对象,其本身的生命周期并不与其指向的资源绑定。当然,这并不意味着使用裸指针与 RAII 概念互斥。当裸指针作为类的成员时,只要通过合适的设计,也可以让这个类满足 RAII。实际上,C++ 标准库容器均符合 RAII,而内部实现均使用了裸指针。

读者可能会有疑问:既然底层都是一样的裸指针,RAII 这个概念有什么意义?表面上 RAII 对单个类的设计来说似乎不值一提,但在复杂的程序中却有难以忽视的价值:对于非派生的类,如果其成员变量都满足 RAII,则这个类自动满足 RAII(析构函数可以直接写“=default”)。对于派生类,只要成员变量与基类满足 RAII,基类有虚析构函数,则这个类也自动满足 RAII。如此一来,即使一个模块由几十、上百个组件构成,开发者在内存安全上付出的精力也将止于那些最基本的、真正需要使用裸指针的组件。考虑到标准库已经提供了很多基本的数据结构,如果开发者正确使用,需要手动管理内存的场合并不多。

【注:标准库容器没有采用虚析构函数,所以一般不继承标准库容器】

从另一个角度来看,RAII 其实点出了一项程序设计的基本原则:面对复杂的资源调度需求,开发者应当在设计时对独立的、能够“自我管理”的概念进行提炼,以这些概念整体——而非这些概念背后具体的资源细节——为零件搭建更为复杂的对象。

2. 所有权

虽然 RAII 确立了对象生命周期与其所管理资源间的基本原则,一个重要问题仍未解决:当对“指针”这一概念的需求不可避免,如何确保内存安全?具体来说,若资源在不同对象间有传递、共享等交互需求,该由什么机制来明确一片资源的管理(比如释放)由哪个对象负责?这个问题实际上引出了“所有权”的概念。

考虑以下一个简化的例子:

class Engine {/* a lot of stuff */};

class Car {
public:
    Car(): engine_(nullptr) {}
    ~Car() { delete engine_; }
    void install(Engine* new_engine) { delete engine_; engine_ = new_engine; }
private:
    Engine* engine_;
};

从概念上,一辆车可能有一台引擎,也可能处于引擎被拆下的状态。若 Engine 类占用很多空间不便复制,似乎成员使用指针,用 nullptr 和非 nullptr 代表不同状态是一种合理的选择。

【另一种可能的选择是全部存对象然后配合 move 使用。这样虽然能避免复制,但是会要求对象拥有移动构造/赋值函数。此外,这种设计下“汽车引擎被卸下”这一状态也需额外处理:要么增加一个 bool 成员用来标记,要么规定 Engine 存在一个"null"状态,两者似乎都不如使用指针自然】

上述代码看似无懈可击,却有一个潜在的问题:install 函数拿到了一个 Engine 的裸指针,这个指针背后的对象生命周期由谁控制?从逻辑来看,当 Car 装上 engine 后,就应当由 Car 来管理 engine 的生命周期。但是,若有粗心的开发者写下以下代码:

void installer(Car* car) {
    Engine engine;
    car.install(&engine); // bad! should be car.install(new Engine);
}

程序依然能编译通过,甚至可能运行。在上述函数中,Engine 对象的生命周期在函数结束时也将结束,但 Car 对象对此完全没有察觉,因此内部 engine_成员将变成悬空指针。该问题不仅在编译阶段无法被过滤,即使在运行期,由于访问悬空指针是未定义行为,也不一定能稳定复现。

尽管上面的例子非常浅显,类似的情况在更复杂的流程中却不一定容易识别。在复杂程序中,变量在开发者设想中的生命周期与实际情况不匹配是内存问题的一大来源。实践中,以下情况并不罕见:

  • 对象 x 原本由对象 A 创建、使用、释放。需求新增后另一个对象 B 需要同 A 共享 x,开发者直接将 A 中 x 的指针复制给了 B。然而 B 的生命周期长于 A,在 A 析构后 B 中存放的 x 指针成为了悬空指针;
  • 多个开发者协作完成某工作,初步讨论后决定某对象 a 由开发者 X 创建,开发者 Y 释放。程序几经迭代后 a 生命周期发生变化,Y 负责的部分已不适合释放 a,但开发者们并未重新明确内存释放的责任,代码合并后产生了内存泄漏。

不难发现,如果能在代码中明确“所有权”这一概念,类似的问题将大大减少。在基于裸指针的对象操作中,“所有权”并不存在于语法层面,只存在于开发者的心中:任意相同类型的指针都可以指向同一片内存,开发者实际上是依据自己对程序逻辑的理解决定哪个指针在哪一步 delete。尽管修复内存问题往往只是增改一两行的工作量,定位问题所消耗的时间与精力却通常不成比例。究其原因,裸指针本身携带的信息过于匮乏,也不主动承担任何管理功能,内存管理的成本必须由开发者通过阅读代码、文档、举行讨论等方式支付。

2.1 智能指针

C++11 引入的智能指针正是针对“所有权”所提出的工具。三种智能指针分别对应以下含义:

【注:auto_ptr 在 C++17 中已被废除,此处不再介绍】

  • unique_ptr: 专属的所有权;
  • shared_ptr:与其他 shared_ptr 共享的所有权;
  • weak_ptr:对 shared_ptr 所管理资源的“访问权”;本身并不具有所有权。

unique_ptr 独有其所指向的资源,相应负有全权管理职责:当一个 unique_ptr 的生命周期结束或被 reset 时,其管理对象的析构函数会被自动调用。此外,unique_ptr 的"="运算符是移动(move)而非拷贝:对于 p1, p2 两个 unique_ptr,运行 "p1 = p2;" 这条命令的后果是(1) p1 原先的对象被释放;(2) 原先 p2 所管理的对象交由 p1 管理,(3) p2 变成空指针。这种设计保证了 unique_ptr 对其所属资源的专属性。unique_ptr 也可通过调用 release()释放这种专属性:返回一个裸指针的同时自身变为空指针。

shared_ptr 与其他指向同一处的 shared_ptr 共同拥有资源。与裸指针相似的是一个 shared_ptr 可以通过"="赋值给另一个 shared_ptr,这时两个 shared_ptr 会指向同一份资源。与裸指针不同的是 shared_ptr 内部有自动计数,在复制时计数加一,在 reset 或生命周期结束时计数减一,计数到零时自动调用析构函数释放资源。

weak_ptr 辅助 shared_ptr 的使用,以“观察者”(不影响计数)的方式指向其他 shared_ptr 的资源。使用 weak_ptr 前需调用 lock()以转化成 shared_ptr。如其他 shared_ptr 依然有效,lock()后将会得到一个非空的 shared_ptr,否则 lock()的结果是一个空的 shared_ptr。

值得注意的是 unique_ptr 与 shared_ptr 都会自动调用析构函数,因此只要原对象满足 RAII,与智能指针结合后将得到一个满足 RAII 的指针,在绝大多数场合可视作裸指针的安全替代。回到先前 Car 与 Engine 的例子中。一种增加程序安全性的方法是采用 unique_ptr 明确 Engine 的所有权转移:

class Car {
public:
    Car() = default; // engine_ defaults to nullptr
    ~Car() = default; // no need to delete
    void install(std::unique_ptr<Engine> new_engine) { engine_ = new_engine; }
private:
    std::unique_ptr<Engine> engine_;
};

如此一来,install()的函数签名将给其他开发者足够的提示:

void installer(Car* car) {
    std::unique_ptr<Engine> engine;
    engine = std::unique_ptr<Engine>(new Engine);
    // or, in c++14, engine = std::make_unique<Engine>(); 
    car.install(engine);
}

在上述代码中,engine 作为一个被 unique_ptr 管理的对象,在作为 install 函数的参数时即完成了所有权的传递【注意到参数类型不包含引用】,在 install 函数内部进一步传递给了 car 的成员变量 engine_。这些信息由语法本身直接提供。

借助智能指针,标准库容器与多态也能完美结合成满足 RAII 的多态容器。比如考虑如下两个容器:

std::vector<BaseClass*>
std::vector<std::unique<BaseClass>>

虽然 std::vector 会自动调用成员的析构函数,但在成员是指针时只会释放指针本身,而不会触及指针指向的对象。因此第一个容器不满足 RAII,释放时需要手动 delete。第二个容器经由 unique_ptr 的析构将自动调用 BaseClass 的析构函数,因而释放时无需额外代码。

【更多关于智能指针的讨论与用法可参考 Scott Meyers 的 Effective Modern C++ 以及 Herb Sutter 的博客:

https://herbsutter.com/2013/05/29/gotw-89-solution-smart-pointers/

https://herbsutter.com/2013/06/05/gotw-91-solution-smart-pointer-parameters/

2.2 注意事项

需要注意的是,智能指针只有正确使用才能实现安全的内存管理;在 C++ 自由的语法下有足够可以让智能指针出错的用法。例如,一个常见的错误是用裸指针来初始化智能指针:

int* raw = new int[100];
std::unique_ptr<int[]> p(raw); // bad! should be std::unique_ptr<int[]> p(new int[100])

上述代码虽然“合法”,但完全破坏了 unique_ptr 的本意。若用户手动 delete raw,在智能指针 p 结束生命周期时会出现重复 delete 的错误。一般而言,智能指针只能管理从一开始就由智能指针管理的内存;将普通创建的对象的指针用来初始化智能指针会引起严重的内存问题。有时人们在设计类时会特意设采用如下模式:

class Test {
public:
    static std::shared_ptr<Test> create() { 
        std::shared_ptr<Test> ptr(new Test);
        return ptr; 
    } 
private:
    Test();
};

通过将构造函数设置为 private,Test 类无法正常创建或者 new 对象,只能通过 create 函数获得由智能指针管理的对象。但即使如此,用户依然可以在调用 create 得到智能指针管理的对象后再通过 get 得到裸指针:

std::shared_ptr<Test> ptr = Test::create();
Test* bad = ptr.get(); // bad practice, but legal
delete bad; // will cause error when ptr expires

上述例子只是说明单纯看到智能指针并不意味着代码的内存安全。事实上,几乎没什么工具能做到在 C++ 语法的自由度下保证不会被用坏。但反过来,即使不使用智能指针,若开发者群体能够自觉遵守一定的规范,代码也可以具有很高的安全性。归根结底,内存的安全管理在实践中并不单取决于一两个工具,更重要的往往是开发者群体的共识。

二、面向对象的设计

1. 封装、内聚与耦合

在面向对象的程序设计中经常出现“封装”这个概念。很多时候,当一个类大致符合以下情况:

  • 包含一系列相互关联的成员变量与函数
  • 一些成员变量有访问限制

我们即会认为做了“封装”。尽管大多数开发者都会写出符合上述条件的代码,但在程序不断演化、代码量逐渐增大时还是十分容易陷入程序结构日益复杂的困境,以至于在进一步开发前不得不重构。

当然,代码的复杂化是功能增多所不可避免的代价,在软件的持续发展中一定次数的重构不可避免。然而,一个并不鲜见的情况是代码在重新设计后不仅比最早的代码更为精简,功能还更为强大。有时我们会意识到,一些需求从一开始就存在更简洁、自然的设计;一部分重构工作在设计之初就有机会避免。诚然,这种想法有事后诸葛之嫌:人们无法预测未来所有的需求,因而也无从设计一个一劳永逸的框架。但是,“不存在一劳永逸的设计”并不能作为所有重构的借口。若我们回顾过去重构的经历,其实不难发现一些高度相似或反复出现的困境。若将一些困境与封装的概念结合起来加以推敲,可以发现至少一部分重构的需求其实与封装的选择密不可分。对于这个因素导致的重构,或许是在今后的开发中较为容易避免且应当避免的。

1.1 封装选择的影响

在设计类或模块时一个不可回避的问题是,应当包含哪些成员与哪些 public 函数?这个问题同时蕴含了其反问题:不应该包含哪些成员与 public 函数?对这两个问题的忽视是导致代码结构不合理地复杂的常见成因之一。以下情况在实际代码中并不罕见:

  1. 单一概念所对应的变量同时是多个类的成员
  2. 几个高度关联的变量被分别存放在不同类或模块里
  3. 一个类有大量(比如几十个)成员变量

1 与 2 的后果都是使用者每次更新一个变量都需要相应修改多个对象。如果这些关联变量位于不同模块,就可能出现两个模块间双向的数据传输。对使用者而言,这种设计不仅增加了理解程序的成本,还造成了一组对象中修改其中一个就必须同步给其他几个的局面。若使用者有所疏忽,写出的程序就可能出错。最糟糕的情况是未正确同步的变量在当前代码流程下未被使用,测试也没能覆盖,而在日后某次新增需求时才被触发。此时可能代码已迭代多轮,故障排查成本极高。

将高度关联的变量分散在各处固然有隐患,可若走向另一极端,将众多变量不假思索地装进一个类里,亦会产生不良的后果。一个拥有大量成员变量的对象往往功能众多,或被多处访问、修改。如果类的维护者选择对所有成员的所有可能情况都负起责任,那他的任务就不仅是几个高度关联变量组的组内同步,还需要负责组间的协调。若这些关联变量组之间是串联关系,工作量尚且只是线性;若这些变量组之间存在灵活的耦合,工作量就会呈指数上升。一种不算少见的情况是,类的设计者原本计划了一个成员众多、功能强大的类,但在开发过程中因精力有限而逐渐放弃对所有计划功能的全权负责,选择将一部分成员的修改权开放给使用者。此时,使用者就不得不追踪并理清其关心的部分成员在整个程序流程中的变化——这对于一个被多处访问、修改的对象是十分繁重且容易出错的任务。

以上讨论其实只指向了一个众所周知的事实:仅在字面上遵循封装往往并不能减轻多少开发者的负担,因而也称不上是好的设计。好的设计需要以“高内聚(high cohesion),低耦合(low coupling)”为目标,直面“应该封装什么”这个问题。

1.2 概念与对象的状态

在尝试回答这个问题前,我们需将之前的例子与面向对象编程中“状态”这一概念结合起来。一个对象的状态通常被定义成这个对象所有成员变量的值;一个程序在某一时刻所有对象内所有成员的值定义了这个程序在此刻的状态。当开发者构思某个功能的实现时,一方面需要理清各个量在概念上的演化,另一方面需要让程序中的实际对象在状态上与概念中的对象对齐。

从这个角度,概念中的对象演化对应了一组关联变量的全体同步;关联变量的不完全同步则不对应任何有意义的概念。如果在程序中一个概念被分散到多个对象,则开发者需要付出“从大量无意义的状态中找到正确状态”的成本。将修改成员变量的权限开放则等于告诉使用者这个类有无数可能的状态,使用者需要自己摸索找到正确的状态。如果没有从设计上确保概念与对象的状态锁定,为使程序正确地在状态间运行,所有欠缺的工作只能由开发者承担。

反过来,如果一个类能做到“状态”与概念严格绑定,其复杂度就可大大降低。以“状态”的视角进行开发,不同开发者的责任分工也得以明确:

  • 设计者需根据需求与程序流程定义出一些细粒度的“状态”,将那些与“状态”的定义最契合的变量打包成类,将不同“状态”间的切换操作定义为 public 函数;
  • 维护者需要理解设计者对状态的定义,同时根据新的需求调整,同时保证类在任何 non-const public 接口调用下只能处于某个预设的状态;
  • 使用者在理解这个类所有可能状态的基础上以黑箱的方式使用,将精力专注于工作流的搭建。

当然,以上的讨论可能过于宽泛或者抽象,也不能涵盖所有的问题。但不可否认的是,仅仅在字面上做到封装对构建复杂软件而言是不够的;开发者值得从更细致的角度进行分析后做出选择(例如不同设计下的耦合强度)。此外,如“高内聚,低耦合”等基本原则也应是开发者不断追求的目标。对于一个类而言,或许下面的要求不算过分:

  • 包含一组且只包含一组高度关联的成员变量
  • 负责修改成员变量的函数应设计成一次性完成所有相关成员变量的同步;
  • 修改成员的操作只能经由采用上述设计的函数完成。

2. 抽象与解耦

在开发 DFT 软件时,类的设计者会很自然地借鉴物理与化学概念,甚至将一些概念直接翻译成类。从程序设计的角度,这种思路下的类一般天然与概念中的类契合,因而往往能满足“高内聚,低耦合”的要求。对于一些具有独特数据结构的量来说,这种做法无可指摘。然而需注意的是,对于一些不同概念的物理量,其数据结构与数学操作也可能存在相当大的共性;即使对于一些整体结构和功能较为独特的量,其部分成员和操作也可能相当常见。当这些情况发生时,基于物理概念的类的设计可能需要一定的调整。

比如,数值原子轨道与赝势的非局域投影子当然是截然不同的物理概念,但两者在数学上都是【数值径向函数】x【球谐函数】的形式,且在 LCAO 计算中需要的数学操作基本相同。基于这些原因,较之于分别定义“数值原子轨道径向函数类”和“赝势投影子径向函数类”,抽象出一个“数值径向函数类”,然后将原子轨道与投影子当作这个类的不同具体对象的做法似乎是更理想的设计。此外,在科学计算中恐怕最为普遍的抽象是对线性代数对象的抽象。C++ 社区内各种线性代数库不胜枚举,熟练使用这些库往往能显著加速开发。

抽象并不局限于对数学概念的提炼。从程序设计角度,有一些类(或者一些类的大部分成员变量)实际上可视作“异质容器” (即存放不同类型变量的容器),其本身并不包含太多操作。遗憾的是,截至 C++11,标准库并不直接提供符合这个概念的工具。【标准库容器配合 boost 的 variant 或 any 可以实现异质容器;C++ 标准库直到 17 才引入 std::variant/any】

除了降低重复代码,抽象对于程序设计的另一大帮助是解耦。若 A 与 B 模块间的接口直接使用 B 模块内的类型,A 模块的编译与测试将无法独立于 B 模块存在。若能将接口替换为更为一般、抽象的对象(比如线性代数对象或者一些标准库容器),A 对 B 模块的依赖就能被去除。

三、宏的使用

1. MPI

当前 module_base 中 MPI 相关函数(parallel_commons/reduce/global)使用宏的方式基本是放在函数外,即当__MPI 未定义时这些 MPI 相关的函数完全不会出现在预处理后的源文件里。这样固然逻辑清晰,但也使得外部每一处 MPI 函数的调用亦需被__MPI 宏包裹,比如

// module_basis/module_nao/atomic_radials.cpp
#ifdef __MPI
    Parallel_Common::bcast_int(lmax_);
    Parallel_Common::bcast_int(nzeta_max_);
// the rest bcasts are omitted
#endif

    if (rank != 0)
    {
        nzeta_ = new int[lmax_ + 1];
        index_map_ = new int[(lmax_ + 1) * nzeta_max_];
    }

#ifdef __MPI
    Parallel_Common::bcast_int(nzeta_, lmax_ + 1);
    Parallel_Common::bcast_int(index_map_, (lmax_ + 1) * nzeta_max_);
#endif

在以上例子中我们需要 bcast 数组 nzeta_与 index_map_。由于这些数组的大小仅为 rank-0 所知,这个信息需要首先被 bcast,其余 rank 在得知大小后申请内存,随后才能 bcast 数组。这个过程中 MPI 函数的调用分为了两段。如果 MPI 函数被集中调用,则尚且只需在头尾分别加上#ifdef 与#endif;如果有间隔地使用 MPI 函数,就需要重复这些宏指令,一定程度上影响了代码的观感。另外,由于程序中使用 MPI 的地方众多,所有文件中重复的#ifdef __MPI ... #endif 数量亦不可小觑。

一种可选的替代方案是只把函数定义的函数体用宏包裹:

// parallel_commons.h
#ifdef __MPI
#include <mpi.h>
#endif

namespace Parallel_Commons {
    void bcast_int(int &object);
    // the rest are omitted
}

// parallel_commons.cpp
void Parallel_Common::bcast_int(int &object){
#ifdef __MPI
    MPI_Bcast(&object, 1, MPI_INT, 0, MPI_COMM_WORLD);
#endif
}

// test.cpp
void test() {
    int i = 5;
    Parallel_Common::bcast_int(&i); // no need to be wrapped in __MPI
}

如此一来,无论__MPI 是否被定义,外部都能使用这些函数,且使用时无需逐块加上#ifdef... #endif。在__MPI 未定义时,MPI 函数的函数体为空,编译器在开启-O2 或更高级别优化下会将这些空函数的调用优化掉,由此实现与当前相同的效果。这个方案要求每个 MPI 函数定义时都分别将函数体用宏包裹,一定程度增加了 parallel_common/reduce/global.cpp 三个文件的代码,但方便了外部的使用。

2. Debug

C++ 各个版本的 标准 中均提到与 <assert.h> 的行为取决于“NDEBUG”宏:只要编译时加上-DNDEBUG,所有 assert 都会成为空函数。

当前 ABACUS 中一方面存在大量标准定义的 assert,同时也存在自定义的“__DEBUG”。虽然新代码中的 assert 被建议使用__DEBUG 包裹,但目前依然存在许多旧有的 assert 并没有被__DEBUG 包裹,这种未统一的状态可能会引起一些开发者的困惑。

此外,目前所有 debug 代码依然需要手动用__DEBUG 包裹,这与__MPI 的情况类似。一种可能的替代方案是

#ifndef __DEBUG_UTILITY_H__
#define __DEBUG_UTILITY_H__

#ifdef __DEBUG // or #ifndef NDEBUG
#include <iostream>
#define DEBUG_PRINT_LINE_AND_FILE() std::cout << __LINE__ << " " << __FILE__ << std::endl;
#else
#define DEBUG_PRINT_LINE_AND_FILE() (void)0
#endif

// some debug utility function which
// is empty when __DEBUG is not defined
void debug_print(...) {
#ifdef __DEBUG // or #ifndef NDEBUG
// do something, e.g. ModuleBase::GlobalFunc::OUT
#endif
}

#endif

如此一来,debug 代码可以在开发中被任意使用,而仅在控制 debug 的宏打开时才起具体作用。