如何编写 C++ 代码
一般建议
1. 以下是一些建议,而非要求。
2. 如果您正在编辑代码,遵循现有代码的格式是有意义的。
3. 代码风格是为了保持一致性。一致性使代码更容易阅读,也使代码更容易搜索。
4. 许多规则没有逻辑上的原因;它们是由既定的实践决定的。
格式
1. 大部分格式将由 clang-format
自动完成。
2. 缩进为 4 个空格。配置您的开发环境,使制表符添加四个空格。
3. 开放和关闭的花括号必须在单独的行上。
inline void readBoolText(bool & x, ReadBuffer & buf)
{
char tmp = '0';
readChar(tmp, buf);
x = tmp != '0';
}
4. 如果整个函数体是一个单独的 statement
,则可以将其放在一行上。在花括号周围放置空格(除了行尾的空格)。
inline size_t mask() const { return buf_size() - 1; }
inline size_t place(HashValue x) const { return x & mask(); }
5. 对于函数。不要在括号周围放置空格。
void reinsert(const Value & x)
memcpy(&buf[place_value], &x, sizeof(x));
6. 在 if
、for
、while
和其他表达式中,在开放括号前插入一个空格(与函数调用相反)。
for (size_t i = 0; i < rows; i += storage.index_granularity)
7. 在二元运算符 (+
、-
、*
、/
、%
,…) 和三元运算符 ?:
周围添加空格。
UInt16 year = (s[0] - '0') * 1000 + (s[1] - '0') * 100 + (s[2] - '0') * 10 + (s[3] - '0');
UInt8 month = (s[5] - '0') * 10 + (s[6] - '0');
UInt8 day = (s[8] - '0') * 10 + (s[9] - '0');
8. 如果输入换行符,请将运算符放在新行上并在其前面增加缩进。
if (elapsed_ns)
message << " ("
<< rows_read_on_server * 1000000000 / elapsed_ns << " rows/s., "
<< bytes_read_on_server * 1000.0 / elapsed_ns << " MB/s.) ";
9. 如果需要,您可以在一行内使用空格进行对齐。
dst.ClickLogID = click.LogID;
dst.ClickEventID = click.EventID;
dst.ClickGoodEvent = click.GoodEvent;
10. 不要在运算符 .
、->
周围使用空格。
如有必要,运算符可以换行。在这种情况下,其前面的偏移量会增加。
11. 不要使用空格将一元运算符 (--
、++
、*
、&
,…) 与参数分隔开。
12. 在逗号后放置一个空格,但在其前面不要放置空格。相同的规则适用于 for
表达式中的分号。
13. 不要使用空格将 []
运算符分隔开。
14. 在 template <...>
表达式中,在 template
和 <
之间使用空格;<
后或 >
前不要使用空格。
template <typename TKey, typename TValue>
struct AggregatedStatElement
{}
15. 在类和结构体中,将 public
、private
和 protected
写在与 class/struct
相同的级别,并缩进其余代码。
template <typename T>
class MultiVersion
{
public:
/// Version of object for usage. shared_ptr manage lifetime of version.
using Version = std::shared_ptr<const T>;
...
}
16. 如果相同的 namespace
用于整个文件,并且没有其他重要内容,则在 namespace
内部不需要偏移量。
17. 如果 if
、for
、while
或其他表达式的块包含单个 statement
,则花括号是可选的。而是将 statement
放在单独的行上。此规则也适用于嵌套的 if
、for
、while
,…
但如果内部 statement
包含花括号或 else
,则外部块应写成花括号。
/// Finish write.
for (auto & stream : streams)
stream.second->finalize();
18. 行尾不应该有任何空格。
19. 源文件采用 UTF-8 编码。
20. 非 ASCII 字符可以用于字符串文字。
<< ", " << (timer.elapsed() / chunks_stats.hits) << " μsec/hit.";
21. 不要在一行中编写多个表达式。
22. 将函数内的代码部分分组,并用不超过一个空行分隔它们。
23. 用一个或两个空行分隔函数、类等。
24. A const
(与值相关)必须写在类型名称之前。
//correct
const char * pos
const std::string & s
//incorrect
char const * pos
25. 在声明指针或引用时,*
和 &
符号的两侧都应由空格分隔。
//correct
const char * pos
//incorrect
const char* pos
const char *pos
26. 使用模板类型时,使用 using
关键字为其创建别名(除了最简单的情况)。
换句话说,模板参数仅在 using
中指定,并且在代码中不会重复。
using
可以局部声明,例如在函数内部。
//correct
using FileStreams = std::map<std::string, std::shared_ptr<Stream>>;
FileStreams streams;
//incorrect
std::map<std::string, std::shared_ptr<Stream>> streams;
27. 不要在一个语句中声明几种不同类型的变量。
//incorrect
int x, *y;
28. 不要使用 C 样式的强制转换。
//incorrect
std::cerr << (int)c <<; std::endl;
//correct
std::cerr << static_cast<int>(c) << std::endl;
29. 在类和结构体中,分别在每个可见性范围内对成员和函数进行分组。
30. 对于小型类和结构体,无需将方法声明与实现分开。
任何类或结构体中的小型方法也是如此。
对于模板类和结构体,不要将方法声明与实现分开(因为否则必须在同一个翻译单元中定义它们)。
31. 您可以将行长度限制在 140 个字符,而不是 80 个。
32. 如果不需要后缀,则始终使用前缀递增/递减运算符。
for (Names::const_iterator it = column_names.begin(); it != column_names.end(); ++it)
注释
1. 务必为所有非平凡的代码部分添加注释。
这一点非常重要。编写注释可能会帮助您意识到代码是不必要的,或者设计错误的。
/** Part of piece of memory, that can be used.
* For example, if internal_buffer is 1MB, and there was only 10 bytes loaded to buffer from file for reading,
* then working_buffer will have size of only 10 bytes
* (working_buffer.end() will point to position right after those 10 bytes available for read).
*/
2. 注释可以根据需要详细。
3. 将注释放在其描述的代码之前。在极少数情况下,注释可以放在代码之后,在同一行上。
/** Parses and executes the query.
*/
void executeQuery(
ReadBuffer & istr, /// Where to read the query from (and data for INSERT, if applicable)
WriteBuffer & ostr, /// Where to write the result
Context & context, /// DB, tables, data types, engines, functions, aggregate functions...
BlockInputStreamPtr & query_plan, /// Here could be written the description on how query was executed
QueryProcessingStage::Enum stage = QueryProcessingStage::Complete /// Up to which stage process the SELECT query
)
4. 注释只能用英文编写。
5. 如果您正在编写库,请在主头文件中包含详细的注释来解释它。
6. 不要添加不提供额外信息的注释。特别是,不要留下这样的空注释
/*
* Procedure Name:
* Original procedure name:
* Author:
* Date of creation:
* Dates of modification:
* Modification authors:
* Original file name:
* Purpose:
* Intent:
* Designation:
* Classes used:
* Constants:
* Local variables:
* Parameters:
* Date of creation:
* Purpose:
*/
此示例借鉴自资源 http://home.tamk.fi/~jaalto/course/coding-style/doc/unmaintainable-code/。
7. 不要在每个文件的开头编写垃圾注释(作者、创建日期等)。
8. 单行注释以三个斜杠开头:///
,多行注释以 /**
开头。这些注释被认为是“文档”。
注意:您可以使用 Doxygen 从这些注释生成文档。但 Doxygen 通常不使用,因为它在 IDE 中导航代码更方便。
9. 多行注释在开头和结尾不能有空行(除了关闭多行注释的那一行)。
10. 为了注释掉代码,请使用基本注释,而不是“文档”注释。
11. 在提交之前删除注释掉的代码部分。
12. 不要在注释或代码中使用脏话。
13. 不要使用大写字母。不要使用过多的标点符号。
/// WHAT THE FAIL???
14. 不要使用注释来创建分隔符。
///******************************************************
15. 不要在注释中开始讨论。
/// Why did you do this stuff?
16. 无需在块的末尾编写描述其内容的注释。
/// for
名称
1. 使用小写字母和下划线作为变量和类成员的名称。
size_t max_block_size;
2. 对于函数(方法)的名称,使用以小写字母开头的 camelCase。
std::string getName() const override { return "Memory"; }
3. 对于类(结构体)的名称,使用以大写字母开头的 CamelCase。除了 I 之外的前缀不用于接口。
class StorageMemory : public IStorage
4. using
的命名方式与类相同。
5. 模板类型参数的名称:在简单情况下,使用 T
;T
、U
;T1
、T2
。
对于更复杂的情况,请遵循类名的规则,或添加前缀 T
。
template <typename TKey, typename TValue>
struct AggregatedStatElement
6. 模板常量参数的名称:遵循变量名的规则,或在简单情况下使用 N
。
template <bool without_www>
struct ExtractDomain
7. 对于抽象类(接口),您可以添加 I
前缀。
class IProcessor
8. 如果您在本地使用变量,则可以使用短名称。
在所有其他情况下,请使用描述其含义的名称。
bool info_successfully_loaded = false;
9. define
和全局常量的名称使用带下划线的全部大写字母。
#define MAX_SRC_TABLE_NAMES_TO_STORE 1000
10. 文件名应与其内容使用相同的样式。
如果文件包含单个类,则文件名称与类名称相同(使用驼峰命名法)。
如果文件包含单个函数,则文件名称与函数名称相同(使用驼峰命名法,首字母小写)。
11. 如果名称包含缩写,则
- 对于变量名称,缩写应使用小写字母 `mysql_connection`(而不是 `mySQL_connection`)。
- 对于类和函数的名称,在缩写中保留大写字母 `MySQLConnection`(而不是 `MySqlConnection`)。
12. 仅用于初始化类成员的构造函数参数,应与类成员名称相同,并在末尾添加下划线。
FileQueueProcessor(
const std::string & path_,
const std::string & prefix_,
std::shared_ptr<FileHandler> handler_)
: path(path_),
prefix(prefix_),
handler(handler_),
log(&Logger::get("FileQueueProcessor"))
{
}
如果参数不在构造函数主体中使用,则可以省略下划线后缀。
13. 局部变量和类成员的名称之间没有区别(不需要前缀)。
timer (not m_timer)
14. 对于 `enum` 中的常量,使用驼峰命名法并以大写字母开头。全部大写也都可以接受。如果 `enum` 不是局部的,请使用 `enum class`。
enum class CompressionMethod
{
QuickLZ = 0,
LZ4 = 1,
};
15. 所有名称必须使用英文。不允许对希伯来语单词进行音译。
not T_PAAMAYIM_NEKUDOTAYIM
16. 如果缩写是众所周知的(您可以在维基百科或搜索引擎中轻松找到缩写的含义),则可以接受。
`AST`, `SQL`.
Not `NVDH` (some random letters)
如果缩写版本是常用用法,则可以接受不完整的单词。
如果完整名称在注释中包含在旁边,您也可以使用缩写。
17. 包含 C++ 源代码的文件必须具有 `.cpp` 扩展名。头文件必须具有 `.h` 扩展名。
如何编写代码
1. 内存管理。
手动内存释放(`delete`)只能在库代码中使用。
在库代码中,`delete` 运算符只能在析构函数中使用。
在应用程序代码中,内存必须由拥有它的对象释放。
示例
- 最简单的方法是将对象放在栈上,或将其作为另一个类的成员。
- 对于大量的小对象,使用容器。
- 对于驻留在堆中的少量对象的自动释放,使用 `shared_ptr/unique_ptr`。
2. 资源管理。
使用 `RAII` 并参见上文。
3. 错误处理。
使用异常。在大多数情况下,您只需要抛出异常,而无需捕获它(因为 `RAII`)。
在离线数据处理应用程序中,通常可以不捕获异常。
在处理用户请求的服务器中,通常只需在连接处理程序的顶层捕获异常。
在线程函数中,您应该捕获并保留所有异常,以便在 `join` 后在主线程中重新抛出它们。
/// If there weren't any calculations yet, calculate the first block synchronously
if (!started)
{
calculate();
started = true;
}
else /// If calculations are already in progress, wait for the result
pool.wait();
if (exception)
exception->rethrow();
不要在没有处理的情况下隐藏异常。不要只是简单地将所有异常放入日志中。
//Not correct
catch (...) {}
如果您需要忽略某些异常,则仅针对特定的异常执行此操作,并重新抛出其余异常。
catch (const DB::Exception & e)
{
if (e.code() == ErrorCodes::UNKNOWN_AGGREGATE_FUNCTION)
return nullptr;
else
throw;
}
当使用具有响应代码或 `errno` 的函数时,始终检查结果,并在发生错误时抛出异常。
if (0 != close(fd))
throw ErrnoException(ErrorCodes::CANNOT_CLOSE_FILE, "Cannot close file {}", file_name);
您可以使用 `assert` 检查代码中的不变式。
4. 异常类型。
在应用程序代码中无需使用复杂的异常层次结构。异常文本应易于系统管理员理解。
5. 从析构函数抛出异常。
不建议这样做,但允许这样做。
使用以下选项
- 创建一个函数(`done()` 或 `finalize()`),它将提前完成所有可能导致异常的工作。如果调用了该函数,则稍后析构函数中不应该有异常。
- 过于复杂的任务(例如通过网络发送消息)可以放在单独的方法中,类用户必须在销毁之前调用该方法。
- 如果析构函数中存在异常,最好记录它而不是隐藏它(如果日志记录器可用)。
- 在简单的应用程序中,可以依靠 `std::terminate`(对于 C++11 中默认情况下 `noexcept` 的情况)来处理异常。
6. 匿名代码块。
您可以在单个函数内创建单独的代码块,以使某些变量成为局部变量,以便在退出块时调用析构函数。
Block block = data.in->read();
{
std::lock_guard<std::mutex> lock(mutex);
data.ready = true;
data.block = block;
}
ready_any.set();
7. 多线程。
在离线数据处理程序中
- 尝试在单个 CPU 内核上获得最佳性能。然后,您可以根据需要并行化代码。
在服务器应用程序中
- 使用线程池处理请求。在这一点上,我们还没有任何需要用户空间上下文切换的任务。
不使用 `fork` 进行并行化。
8. 线程同步。
通常可以使不同的线程使用不同的内存单元(甚至更好:不同的缓存行),并且不使用任何线程同步(除了 `joinAll`)。
如果需要同步,在大多数情况下,使用 `lock_guard` 下的互斥锁就足够了。
在其他情况下,使用系统同步原语。不要使用忙等待。
原子操作应仅在最简单的情况下使用。
除非它是您的主要专业领域,否则不要尝试实现无锁数据结构。
9. 指针与引用。
在大多数情况下,更喜欢引用。
10. `const`。
使用常量引用、指向常量的指针、`const_iterator` 和 `const` 方法。
将 `const` 视为默认值,仅在必要时使用非 `const`。
当按值传递变量时,使用 `const` 通常没有意义。
11. `unsigned`。
如果需要,使用 `unsigned`。
12. 数值类型。
使用 `UInt8`、`UInt16`、`UInt32`、`UInt64`、`Int8`、`Int16`、`Int32` 和 `Int64` 以及 `size_t`、`ssize_t` 和 `ptrdiff_t` 类型。
不要将这些类型用于数字:`signed/unsigned long`、`long long`、`short`、`signed/unsigned char`、`char`。
13. 传递参数。
如果要移动复杂值,则按值传递它们并使用 `std::move`;如果要在循环中更新值,则按引用传递。
如果函数捕获了在堆中创建的对象的所有权,则将参数类型设为 `shared_ptr` 或 `unique_ptr`。
14. 返回值。
在大多数情况下,只需使用 `return`。不要写 `return std::move(res)`。
如果函数在堆上分配了一个对象并返回它,则使用 `shared_ptr` 或 `unique_ptr`。
在极少数情况下(在循环中更新值),您可能需要通过参数返回值。在这种情况下,参数应为引用。
using AggregateFunctionPtr = std::shared_ptr<IAggregateFunction>;
/** Allows creating an aggregate function by its name.
*/
class AggregateFunctionFactory
{
public:
AggregateFunctionFactory();
AggregateFunctionPtr get(const String & name, const DataTypes & argument_types) const;
15. `namespace`。
应用程序代码不需要使用单独的 `namespace`。
小型库也不需要这样做。
对于中大型库,将所有内容放在 `namespace` 中。
在库的 `.h` 文件中,您可以使用 `namespace detail` 来隐藏应用程序代码不需要的实现细节。
在 `.cpp` 文件中,您可以使用 `static` 或匿名 `namespace` 来隐藏符号。
此外,`namespace` 可用于 `enum`,以防止相应的名称落入外部 `namespace`(但最好使用 `enum class`)。
16. 延迟初始化。
如果初始化需要参数,则通常不应编写默认构造函数。
如果稍后需要延迟初始化,您可以添加一个默认构造函数,它将创建一个无效的对象。或者,对于少量对象,您可以使用 `shared_ptr/unique_ptr`。
Loader(DB::Connection * connection_, const std::string & query, size_t max_block_size_);
/// For deferred initialization
Loader() {}
17. 虚函数。
如果类并非旨在用于多态使用,则无需将函数设为虚函数。这也适用于析构函数。
18. 编码。
在任何地方都使用 UTF-8。使用 `std::string` 和 `char *`。不要使用 `std::wstring` 和 `wchar_t`。
19. 日志记录。
请参阅代码中随处可见的示例。
在提交之前,删除所有无意义的和调试日志,以及任何其他类型的调试输出。
应避免在循环中进行日志记录,即使在跟踪级别也是如此。
日志必须在任何日志记录级别都可读。
日志记录主要应仅用于应用程序代码。
日志消息必须使用英文编写。
日志最好易于系统管理员理解。
不要在日志中使用脏话。
在日志中使用 UTF-8 编码。在极少数情况下,您可以在日志中使用非 ASCII 字符。
20. 输入输出。
不要在对应用程序性能至关重要的内部循环中使用 `iostreams`(并且永远不要使用 `stringstream`)。
改用 `DB/IO` 库。
21. 日期和时间。
请参阅 `DateLUT` 库。
22. `include`。
始终使用 `#pragma once` 代替包含保护。
23. `using`。
不使用 `using namespace`。您可以将 `using` 用于特定内容。但请将其设为类或函数内部的局部内容。
24. 除非必要,否则不要对函数使用 `trailing return type`。
auto f() -> void
25. 变量的声明和初始化。
//right way
std::string s = "Hello";
std::string s{"Hello"};
//wrong way
auto s = std::string{"Hello"};
26. 对于虚函数,在基类中编写 `virtual`,但在派生类中编写 `override` 而不是 `virtual`。
C++ 未使用的特性
1. 不使用虚拟继承。
2. 在现代 C++ 中具有方便语法糖的构造,例如
// Traditional way without syntactic sugar
template <typename G, typename = std::enable_if_t<std::is_same<G, F>::value, void>> // SFINAE via std::enable_if, usage of ::value
std::pair<int, int> func(const E<G> & e) // explicitly specified return type
{
if (elements.count(e)) // .count() membership test
{
// ...
}
elements.erase(
std::remove_if(
elements.begin(), elements.end(),
[&](const auto x){
return x == 1;
}),
elements.end()); // remove-erase idiom
return std::make_pair(1, 2); // create pair via make_pair()
}
// With syntactic sugar (C++14/17/20)
template <typename G>
requires std::same_v<G, F> // SFINAE via C++20 concept, usage of C++14 template alias
auto func(const E<G> & e) // auto return type (C++14)
{
if (elements.contains(e)) // C++20 .contains membership test
{
// ...
}
elements.erase_if(
elements,
[&](const auto x){
return x == 1;
}); // C++20 std::erase_if
return {1, 2}; // or: return std::pair(1, 2); // create pair via initialization list or value initialization (C++17)
}
平台
1. 我们为特定平台编写代码。
但在其他条件相同的情况下,更偏好跨平台或可移植的代码。
2. 语言:C++20(请参阅可用 C++20 特性 的列表)。
3. 编译器:`clang`。在撰写本文时(2022 年 7 月),代码使用 clang 版本 >= 12 编译。(它也可以使用 `gcc` 编译,但尚未经过测试,不适合生产使用)。
使用标准库(`libc++`)。
4. 操作系统:Linux Ubuntu,不早于 Precise。
5. 代码针对 x86_64 CPU 架构编写。
CPU 指令集是我们服务器中支持的最低指令集。目前,它是 SSE 4.2。
6. 使用 `-Wall -Wextra -Werror -Weverything` 编译标志,但有一些例外。
7. 与所有库进行静态链接,除了那些难以静态连接的库(请参阅 `ldd` 命令的输出)。
8. 代码使用发布设置进行开发和调试。
工具
1. KDevelop 是一款不错的 IDE。
2. 对于调试,使用 `gdb`、`valgrind`(`memcheck`)、`strace`、`-fsanitize=...` 或 `tcmalloc_minimal_debug`。
3. 对于性能分析,使用 `Linux Perf`、`valgrind`(`callgrind`)或 `strace -cf`。
4. 源代码位于 Git 中。
5. 构建使用 `CMake`。
6. 程序使用 `deb` 包发布。
7. 对主分支的提交不得破坏构建。
尽管只有选定的版本被认为是可用的。
8. 尽可能频繁地进行提交,即使代码仅部分准备就绪。
为此目的,请使用分支。
如果您的 master
分支中的代码尚不可构建,请在 push
之前将其排除在构建之外。您需要在几天内完成或删除它。
9. 对于非简单更改,请使用分支并在服务器上发布它们。
10. 未使用的代码将从存储库中删除。
库
1. 使用 C++20 标准库(允许使用实验性扩展),以及 boost
和 Poco
框架。
2. 不允许使用来自操作系统软件包的库。也不允许使用预安装的库。所有库都应以源代码的形式放置在 contrib
目录中,并使用 ClickHouse 构建。有关详细信息,请参阅 添加第三方库的指南。
3. 始终优先使用已在使用的库。
常规建议
1. 尽可能少写代码。
2. 尝试最简单的解决方案。
3. 在您知道它将如何工作以及内部循环将如何运行之前,不要编写代码。
4. 在最简单的情况下,使用 using
而不是类或结构体。
5. 如果可能,不要编写复制构造函数、赋值运算符、析构函数(如果类包含至少一个虚函数,则除了虚析构函数之外)、移动构造函数或移动赋值运算符。换句话说,编译器生成的函数必须能够正确工作。您可以使用 default
。
6. 鼓励代码简化。尽可能减少代码量。
其他建议
1. 显式指定来自 stddef.h
的类型的 std::
不推荐。换句话说,我们建议编写 size_t
而不是 std::size_t
,因为它更短。
添加 std::
是可以接受的。
2. 显式指定来自标准 C 库的函数的 std::
不推荐。换句话说,编写 memcpy
而不是 std::memcpy
。
原因是存在类似的非标准函数,例如 memmem
。我们确实偶尔会使用这些函数。这些函数不存在于 namespace std
中。
如果您在所有地方都编写 std::memcpy
而不是 memcpy
,那么没有 std::
的 memmem
看起来很奇怪。
但是,如果您更喜欢它,您仍然可以使用 std::
。
3. 在标准 C++ 库中提供相同函数的情况下使用来自 C 的函数。
如果效率更高,这是可以接受的。
例如,对于复制大块内存,使用 memcpy
而不是 std::copy
。
4. 多行函数参数。
允许使用以下任何一种包装样式
function(
T1 x1,
T2 x2)
function(
size_t left, size_t right,
const & RangesInDataParts ranges,
size_t limit)
function(size_t left, size_t right,
const & RangesInDataParts ranges,
size_t limit)
function(size_t left, size_t right,
const & RangesInDataParts ranges,
size_t limit)
function(
size_t left,
size_t right,
const & RangesInDataParts ranges,
size_t limit)