在座的各位将来大多数会成为学术界的大牛,想出一个idea,写一点能用就行的代码,发出paper来就行。自然会有一大群工程师围绕这个主意,使用各种神奇方法,写出又好又快的工程来。但可惜各位目前还不是这样的大牛。同时,这样的大牛通常都会有过硬的工程本领,毕竟”能用就行“的代码写起来也不是那么简单,糟糕的工程会浪费会多人很多的宝贵时间,很多 idea 也因此而夭折,这里面最著名的应该就是二战时德国的原子弹(谢天谢地)。
超算作为高度复杂的综合系统,加上几十年来各种代码的缝缝补补,其中自然充满了上百万行后人完全不敢动的代码。我们要面对这样的工程,但我们也要有一些自己的品味。下面我们介绍一些理念,希望能给你一些启发。
最基本的代码风格包括代码的缩进以及命名规范。在一个较为大型的多人项目中,代码风格是需要统一的,对避免工程复杂度爆炸,方便多人协作有重要的作用,比如著名的 Linux 内核项目就有一套完备的代码风格规范。同时,遵守一套固定的代码风格规范是一个合格工程师最基本的素养之一。
对于C++
,你可以借鉴一些著名的规范比如 Google C++ Style Guide、LLVM Coding Standards 等,也可以自己制定一套代码规范。无论是哪种方式,目的都是要让项目代码有统一的风格,并且尽可能的直观易懂。
同时,我们推荐使用代码自动格式化工具,比如 clang-format
来自动格式化你的代码--这么无趣且有用的工作肯定会有自动化工具的,你要相信这一点。
在你的 IDE 中,使用
clang-format
进行代码格式化。
如果你还没有熟悉的代码风格,可以使用
本节以 gcc
工具链为例。
现代的 IDE 往往会把编译过程给封装好,初学者接触的时候可能会不明白其中的原理。其实非常简单,无论是哪个 IDE,无论是哪种构建工具,其核心就是在于调用 g++
、ln
等命令行工具。
对于多文件工程而言,我们虽然一般不会手动用 g++
等进行编译,但我们仍然需要理解其中的原理。
详细的阅读资料:
谷雨同学的C++教程:在CLI中使用编译器
本节以 CMake
为例
对于多文件工程而言,使用 g++
等直接在命令行编译无疑过于麻烦,我们需要一个更方便的构建工具来帮助我们管理项目。
GNU Make
是一个非常经典的构建工具,可以通过编写 makefile
,然后执行 make
命令自动构建多文件程序。并且会保存构建过程中的中间文件,下一次修改后再编译时,make
会自动识别哪些文件被修改了,或者说需要重新编译,然后只对需要重编译的文件进行编译,大大提高了构建的效率。但是其用到的 makefile
的编写较为麻烦。你可以看这篇文章来学习 make
的使用:Make 命令教程。
为了解决这些问题,CMake
应运而生。CMake
通过 CMakeLists.txt
管理项目。CMakeLists.txt
相比起 makefile
而言更加符合项目结构的管理,也减少了对特定平台的依赖,换句话说,使用 CMake
工具管理的项目往往是支持在不同平台上构建的。CMake
一般通过生成 makefile
或 .ninja
来调用 make
或 ninja
等更底层的工具来构建项目。
你可以阅读 CMake Tutorial 来学习 CMake
的基本使用。
对于一个健全的项目而言,测试是十分重要的,在一些关键性的项目中,甚至会要求百分百测试率。因为在复杂系统中,你很难想象自己的代码会因为什么而 fail 。
软件测试分为白盒测试和黑盒测试,其基本方式是根据特定的标准设计测试用例,然后编写自动测试程序测试程序是否表现出预期行为。如果编写自动测试程序的代价过大,也可以设计手动测试流程进行测试。
白盒测试根据软件的代码进行,基于代码覆盖率。常见的代码覆盖率标准有
下例
int f(int a, int b) {
if (a > 0 && b > 0) {
a = a + b;
} else {
a = a - b;
}
if (b % 2 == 0){
b += 1;
}
return a * b
}
在这个例子中,根据不同代码覆盖率标准设计的**覆盖率为100%**的测试用例如下:
黑盒测试根据软件的规约,即需要在不知道软件内部具体实现的情况下发现软件的行为是否符合预期。
设计黑盒测试用例常根据两个标准:等价类划分和边界值分析
等价类划分是指把输入的取值域划分为多个等价类。比如一个输入学生成绩,输出是否及格(>=60分)的程序中,等价类就可以分为小于60分和大于等于60分。划分等价类后取中间值设计测试用例,比如在上例中就可以设计输入分别为30分和80分的两个测试用例。
边界值分析是指在等价类的边界取值,测试程序是否能够处理边界情况。比如在上例中,边界值就可以取60分。
C++ 不像 Rust、Go 里面有一个标准的测试框架,C++ 中的测试框架均为第三方的,美其名曰具有丰富的多样性。一些著名的测试框架有 Boost Test、Google Test、Doctest 等等。
hacking C++ 中列举了若干单元测试框架,并对 Doctest
和 Catch 2
做了详细的介绍。建议阅读并学习使用至少一个单元测试框架。
CI/CD 指持续集成(continuous integration)和持续交付(continuous delivery)。对于软件开发而言,其关键实践在于每次对于代码的更改被合并到主分支时,系统就运行自动化构建程序并运行不同级别的自动化测试,以确保代码的更改是正确的,不会影响到其他部分的功能。
配合上前述的自动构建和自动测试,工程的每一次变更都会在你的掌控之中。
可以阅读这篇文章来详细了解 CI/CD。
Github Action 是目前非常流行的 CI/CD 工具,推荐了解其基本使用方法。
虽然 c++11
之后用上了智能指针,但是内存安全问题还是没有办法完全避免。比如在使用 shared_ptr
的时候,就可能会出现循环引用的情况。这些内存安全问题有时候不会导致立即的程序崩溃,比如内存泄漏往往不会导致程序异常,除非内存泄漏到一定量级,影响了系统的正常运行。但是,对于一个健全的程序而言,这些内存安全问题无论是否对程序产生了立即的影响,都是应该要尽量避免的。
valgrind
是一款用与构建动态分析工具的指令框架,其中也包含了用此框架构建的内存和线程分析工具等。我们只需要使用其中的 memcheck
工具,就可以便利地发现程序中的内存安全问题。
valgrind
的安装以 Ubuntu-22.04lts
为例。
sudo apt install valgrind # 没错,就是这么简单
memcheck
工具的基本使用使用 memcheck
工具之前,需要先保证待测程序在编译时加入了调试信息。比如在 g++
和 clang++
中,就可以加上命令行参数 -g
来将调试信息保存到输出程序之中。
我们以下面一段非常简单的有内存泄漏的程序为例,这段程序分配了 800 bytes 的堆上内存却没有释放,即发生了内存泄漏问题。
// memleak.cpp
int main(){
double* a = new double[100];
return 0;
}
我们使用 g++
编译
g++ -g memleak.cpp -o memleak
然后运行 memcheck
工具
valgrind --tool=memcheck ./memleak
这时候 valgrind
就会自动执行程序并检测其中的内存安全问题。在这个例子中,我们会获得如下输出
==7996== Memcheck, a memory error detector
==7996== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==7996== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==7996== Command: ./mem
==7996==
==7996==
==7996== HEAP SUMMARY:
==7996== in use at exit: 800 bytes in 1 blocks
==7996== total heap usage: 2 allocs, 1 frees, 74,528 bytes allocated
==7996==
==7996== LEAK SUMMARY:
==7996== definitely lost: 800 bytes in 1 blocks
==7996== indirectly lost: 0 bytes in 0 blocks
==7996== possibly lost: 0 bytes in 0 blocks
==7996== still reachable: 0 bytes in 0 blocks
==7996== suppressed: 0 bytes in 0 blocks
==7996== Rerun with --leak-check=full to see details of leaked memory
==7996==
==7996== For lists of detected and suppressed errors, rerun with: -s
==7996== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
我们可以看到程序中 800 bytes 的内存泄漏被检测出来了。
当然,不只是内存泄漏,其他类型的内存安全问题这个工具也能够检测出来。
敏捷开发是一种软件开发过程,适合小团队开发快速迭代、需求多变的项目。
其核心内容包括:
下面介绍敏捷开发中的几个关键实践
测试驱动开发要求在编写某个功能前先根据规约编写详细的测试用例。开始时这些用例应该会全部 failed
,程序员通过一次解决一个 failed
的方式,一步步地将功能实现完成,最终使得全部测试用例通过。
"Premature optimization is the root of all evil"
--Donald Knuth
你会理解的。就比如说你通过循环展开优化了1%的性能,后面的同学费了老鼻子力气在循环里加了一些另外的功能。再过几个月你想在循环的某些情况写进行特判,然后发现这段代码你根本不认识。超算领域有时候需要卡这1%的性能,但需要平衡--笔者相信由编译器自动展开会是更好的实践。