编码规范

编码标准

有哪些好的C++编码规范?

感谢您阅读此回答,而不是仅仅尝试设定自己的编码规范。

C++编码规范的主要目的是为在特定环境中实现特定目的而使用C++提供一套规则。因此,不可能存在适用于所有用途和所有用户的一种编码规范。对于给定的应用程序(或公司、应用领域等),一个好的编码规范胜过没有编码规范。另一方面,我们已经看到了许多例子表明,一个糟糕的编码规范比没有编码规范更糟。请仔细选择您的规则,并对您的应用领域有扎实的了解。一些最糟糕的编码规范(为了“保护有罪者”,我们不会提及名称)是由对C++缺乏扎实了解,并且对应用领域相对无知的人编写的(他们是“专家”而不是开发人员),他们错误地认为更多的限制必然优于更少的限制。最后一个误解的反例是,某些功能的存在是为了帮助程序员不得不使用更糟糕的功能。无论如何,请记住,安全性、生产力等是设计和开发过程所有部分的总和——而不是单个语言特性,甚至不是整个语言的总和。

鉴于这些注意事项,我们建议四点:

  • 查阅C++核心准则。它们是由Bjarne Stroustrup领导的协作努力,就像C++语言本身一样。它们是许多人年(person-years)在多个组织中讨论和设计的结果。其设计鼓励普遍适用和广泛采用,但可以自由复制和修改以满足您组织的需求。
  • 查阅Sutter和Alexandrescu的《C++编码规范》。它提供了101条规则、指南和最佳实践。作者和编辑制作了一些扎实的材料,然后出色地激发了同行评审团队的活力。所有这些都改进了这本书。购买它。它有很好的具体规则,但不会涵盖适用于您团队的所有规则。因此,也要将其视为一个典范和指南,了解一套好的、更具体的编码规则应该是什么样子。如果您正在编写编码规范,忽视这本书将会有风险。
  • 查阅JSF航空器C++编码规范。Stroustrup认为这是一套相当好的针对安全关键和性能关键代码的规则。如果您进行嵌入式系统编程,您应该考虑它。如果您不构建硬实时系统或安全关键系统,您会发现这些规则过于严格——因为这些规则不适合您(至少不是所有这些规则)。
  • 不要使用C语言编码规范(即使经过C++的轻微修改),也不要使用十年前的C++编码规范(即使在当时是好的)。C++不只是C,标准C++不只是前标准C++。

一个警告:几乎每位软件工程师都曾一度被那些将编码规范作为“权力游戏”的人所利用。对细节的教条主义是智力薄弱者的特权。不要像他们那样。这些人无法做出任何有意义的贡献,无法真正提升软件产品的价值,因此他们不是通过沉默暴露自己的无能,而是热情洋溢地喋喋不休地谈论细枝末节。他们无法在软件的实质上增加价值,因此他们争论形式。然而,仅仅因为“他们”这样做并不意味着编码规范是坏的。

另一种对编码规范的情绪反应是由技能过时的个人设定的编码规范引起的。例如,某人可能根据N年前这位规范制定者编写代码时编程的样子来设定今天的标准。这种强加会产生对编码规范的不信任感。如上所述,如果您被迫忍受了这种不幸的经历,不要让它使您对编码规范的整体意义和价值感到厌倦。即使是一个很小的组织,也能发现保持一致性是有价值的,因为不同的程序员可以编辑同一段代码,而不会在“最佳”编码规范的拉锯战中不断重组彼此的代码。

编码规范是必要的吗?它们足够吗?

编码规范不能把非OO程序员变成OO程序员;只有培训和经验才能做到这一点。如果编码规范有优点,那就是它们能阻止大型组织在协调不同程序员群体活动时发生的细微碎片化。

但你真正想要的不仅仅是一个编码规范。编码规范提供的结构让新手少了一个需要担心的问题,这很好。然而,实用的指导方针应该远远超出美化打印标准。组织需要一个一致的设计和实现理念。例如,强类型还是弱类型?接口中使用引用还是指针?流I/O还是stdio?C++代码应该调用C代码吗?反之亦然?抽象基类(ABCs)应该如何使用?继承应该作为实现技术还是规范技术来使用?应该采用何种测试策略?检查策略?接口是否应该统一为每个数据成员提供一个get()和/或set()成员函数?接口应该从外部设计还是从内部设计?错误应该通过try/catch/throw处理还是通过返回码处理?等等。

所需的是一个关于详细设计的“伪标准”。我建议采用三管齐下的方法来实现这种标准化:培训、指导和库。培训提供“密集指导”,指导允许OO被“领悟”而非仅仅“传授”,而高质量的C++类库则提供“长期指导”。这三种“培训”在商业市场蓬勃发展。经历过考验的组织提供的建议是一致的:购买,而非构建。购买库,购买培训,购买工具,购买咨询。那些试图在成为应用程序/系统开发商的同时成为自学工具开发商的公司发现成功是困难的。

很少有人争论编码规范是“理想的”或甚至是“好的”,然而在上述类型的组织/情况下它们是必要的。

以下FAQ提供了一些关于约定和风格的基本指导。

我们的组织是否应该根据我们的C经验来确定编码规范?

不!

无论您的C经验多么丰富,无论您的C专业知识多么深厚,成为一名优秀的C程序员并不能使您成为一名优秀的C++程序员。从C到C++的转变不仅仅是学习C++中++部分的语法和语义。那些希望获得OO承诺,但未能将“OO”融入“OO编程”的组织是在自欺欺人;资产负债表将显示他们的愚蠢。

C++编码规范应由C++专家来锤炼。询问comp.lang.c++是一个开始。寻找能帮助您避开陷阱的专家。接受培训。购买库并查看“好”库是否符合您的编码规范。不要独自制定规范,除非您在C++方面有相当丰富的经验。没有规范比有坏规范更好,因为不恰当的“官方”立场会“固化”错误的思维模式。C++培训和库都有蓬勃发展的市场,从中可以汲取专业知识。

还有一件事:当某种东西需求旺盛时,骗子的可能性也会增加。三思而后行。此外,请向以前的公司索取学生评价,因为即使是专业知识也无法保证一个人是好的沟通者。最后,选择一位能够教学的实践者,而不是一位对语言/范式只有一般了解的全职教师。

<cxxx><xxx.h> 头文件有什么区别?

ISO标准C++中的头文件没有.h后缀。这是标准委员会从以前的做法中改变的。C语言中已有的头文件和C++特有的头文件之间有不同的细节。

C++标准库保证包含18个来自C语言的标准头文件。这些头文件有两种标准形式,<cxxx><xxx.h>(其中xxx是头文件的基本名称,例如stdiostdlib等)。这两种形式除了<cxxx>版本只在std命名空间中提供其声明,而<xxx.h>版本在std命名空间和全局命名空间中都可用之外,是相同的。委员会这样做是为了让现有的C代码可以继续在C++中编译。然而,<xxx.h>版本已被弃用,这意味着它们现在是标准的一部分,但将来修订时可能不再是标准的一部分。(参见ISO C++标准的D.5条款ISO C++ standard。)

C++标准库还保证有32个额外的标准头文件,它们在C中没有直接对应物,例如<iostream><string><new>。你可能会在旧代码中看到#include <iostream.h>等,一些编译器厂商也因此提供.h版本。但请注意:如果.h版本可用,它们可能与标准版本有所不同。而且,如果你程序的某些单元用<iostream>编译,而另一些用<iostream.h>编译,程序可能无法正常工作。

对于新项目,只使用 <cxxx> 头文件,不使用 <xxx.h> 头文件。

在修改或扩展使用旧头文件名称的现有代码时,您应该遵循该代码中的做法,除非有重要原因需要切换到标准头文件(例如,标准<iostream>中提供了供应商<iostream.h>中没有的功能)。如果您需要对现有代码进行标准化,请确保更改所有程序单元中的所有C++头文件,包括链接到最终可执行文件中的外部库。

所有这些都只影响标准头文件。您可以自由地对您自己的头文件使用不同的命名约定

我应该在代码中使用using namespace std吗?

可能不行。

人们不喜欢一遍又一遍地输入 std::,他们发现 using namespace std 允许编译器识别任何 std 名称,即使没有限定。但这其中有个问题,它会让编译器识别任何 std 名称,甚至包括你没有想到的那些。换句话说,它可能产生名称冲突和歧义。

例如,假设你的代码正在计数,并且你碰巧使用了名为count的变量或函数。但std库也使用了count这个名称(它是std算法之一),这可能会导致歧义。

瞧,命名空间的整个目的是为了防止两个独立开发的代码块之间发生命名空间冲突。using-directive(这是using namespace XYZ的技术名称)实际上将一个命名空间的内容倾倒到另一个命名空间中,这可能会破坏该目标。using-directive的存在是为了兼容旧的C++代码,并简化向命名空间的过渡,但你可能不应该经常使用它,至少在你的新C++代码中不应该。

如果你真的想避免输入std::,那么你可以使用一种叫做using-declaration的东西,或者干脆克服它,直接输入std::(非解决方案)。

  • 使用using-declaration,它引入特定的、选定的名称。例如,为了让您的代码可以在不带std::限定符的情况下使用名称cout,您可以在代码中插入using std::cout。这不太可能引起混淆或歧义,因为您引入的名称是明确的。

    #include <vector>
    #include <iostream>
    
    void f(const std::vector<double>& v)
    {
     using std::cout;  // ← a using-declaration that lets you use cout without qualification
    
     cout << "Values:";
     for (auto value : v)
       cout << ' ' << value;
     cout << '\n';
    }
    
  • 克服它并直接输入 std::(非解决方案)

    #include <vector>
    #include <iostream>
    
    void f(const std::vector<double>& v)
    {
     std::cout << "Values:";
     for (auto value : v)
       std::cout << ' ' << value;
     std::cout << '\n';
    }
    

我个人觉得输入“std::”比为每个不同的std名称决定是否包含using-declaration,以及如果包含,在哪里找到最佳作用域并添加它要快。但这两种方式都可以。请记住,你是一个团队的一员,所以确保你使用的方法与组织的其他部分保持一致。

?: 操作符是邪恶的吗,因为它可能导致难以阅读的代码?

不,但一如既往,请记住可读性是最重要的事情之一。

有些人认为应该避免使用?:三元运算符,因为他们有时会觉得它与传统的if语句相比令人困惑。在许多情况下,?:会使你的代码更难阅读(因此你应该用if语句替换那些使用?:的地方),但有时?:运算符更清晰,因为它可以强调真正发生的事情,而不是仅仅强调某个地方有一个if

我们从一个非常简单的例子开始。假设你需要打印一个函数调用的结果。在这种情况下,你应该把真正的目标(打印)放在行的开头,并将函数调用埋在线内,因为它相对来说是次要的(这种左右顺序基于直观的观念,即大多数开发者认为一行中的第一个事物是最重要的)。

// Preferred (emphasizes the major goal — printing):
std::cout << funct();

// Not as good (emphasizes the minor goal — a function call):
functAndPrintOn(std::cout);

现在我们将这个想法扩展到?:运算符。假设您的真正目标是打印一些东西,但您需要做一些附带的决策逻辑来确定应该打印什么。由于打印在概念上是最重要的,我们倾向于将其放在行的开头,并且我们倾向于将附带的决策逻辑隐藏起来。在下面的示例代码中,变量n表示消息发送者的数量;消息本身正在打印到std::cout

int n = /*...*/;   // number of senders

// Preferred (emphasizes the major goal — printing):
std::cout << "Please get back to " << (n==1 ? "me" : "us") << " soon!\n";

// Not as good (emphasizes the minor goal — a decision):
std::cout << "Please get back to ";
if (n == 1)
  std::cout << "me";
else
  std::cout << "us";
std::cout << " soon!\n";

话虽如此,使用?:&&||等各种组合,你可能会写出相当离谱和难以阅读的代码(“只写代码”)。例如:

// Preferred (obvious meaning):
if (f())
  g();

// Not as good (harder to understand):
f() && g();

我个人认为 if (f()) g()f() && g() 更清晰,因为前者强调了正在发生的主要事情(根据调用 f() 的结果做出决定),而不是次要的事情(调用 f())。换句话说,这里使用 if好的,原因恰好与它在上面是坏的原因相同:我们希望关注主要矛盾,次要矛盾次之。

无论如何,不要忘记可读性是目标(至少是目标之一)。你的目标应该是避免某些语法结构,例如?:&&||if——甚至goto。如果你堕落到“标准偏执狂”的层面,你最终会让自己难堪,因为任何基于语法的规则总会有反例。另一方面,如果你强调宽泛的目标和指导方针(例如,“关注主要方面”,或“把最重要的东西放在行首”,甚至“确保你的代码清晰易读”),你通常会好得多。

代码是写给人看的,而不是给编译器看的。

我应该在函数中间声明局部变量还是在函数顶部声明?

在首次使用附近声明。

对象在声明时就被初始化(构造)。如果你在函数执行到一半时才拥有足够的信息来初始化一个对象,那么你应该在函数执行到一半时创建它,这样它才能被正确初始化。不要在顶部将其初始化为“空”值,然后稍后“赋值”。这样做的原因是运行时性能。正确地构建一个对象比不正确地构建它然后稍后重构它要快。简单的例子显示,对于像String这样的简单类,速度会降低350%。您的具体情况可能有所不同;当然,整体系统性能下降会低于350%,但确实会下降。不必要的下降。

一个常见的反驳是:“我们将为对象中的每个数据提供set()成员函数,这样构造的成本就会分散开来。”这比性能开销更糟,因为现在你引入了一个维护噩梦。为每个数据提供一个set()成员函数等同于public数据:你已经向世界暴露了你的实现技术。你唯一隐藏的是成员对象的物理名称,但例如你正在使用ListStringfloat的事实是所有人都可以看到的。

底线:局部变量应该在首次使用附近声明。抱歉这对于C专家来说不熟悉,但新事物不一定意味着坏事。

哪种源文件命名约定最好?foo.cppfoo.Cfoo.cc

如果您已经有约定,请使用它。如果没有,请咨询您的编译器以了解编译器期望什么。典型答案是:.cpp.C.cc.cxx(自然,.C扩展名假定文件系统区分大小写,以区分.C.c)。

我们经常将.cpp用于C++源文件,也曾使用.C。在后一种情况下,当移植到不区分大小写的文件系统时,您需要告诉编译器将.c文件视为C++源文件(例如,IBM CSet++使用-Tdp,Zortech C++使用-cpp,Borland C++使用-P等)。

关键是,这些文件名扩展名都没有哪一个比其他更优越。我们通常采用客户偏好的技术(再次强调,这些问题主要受商业考虑而非技术考虑驱动)。

哪种头文件名约定最好?foo.Hfoo.hhfoo.hpp

如果您已经有约定,请使用它。如果没有,并且您不需要编辑器区分C和C++文件,只需使用.h。否则,请使用编辑器想要的任何约定,例如.H.hh.hpp

我们倾向于将.h.hpp用于我们的C++头文件。

C++有没有类似lint的准则?

是的,有一些做法通常被认为是危险的。然而,这些都不是普遍“坏”的,因为在某些情况下,即使是最糟糕的这些做法也是必要的。

  • 一个class Fred的赋值operator应该返回*this作为Fred&(允许赋值链式调用)
  • 一个包含任何virtual函数的类应该有一个虚析构函数
  • 一个类如果含有以下任意一个:析构函数、拷贝赋值运算符、拷贝构造函数、移动赋值运算符、移动构造函数,通常需要所有5个。
  • 一个class Fred的拷贝构造函数和赋值运算符的参数中应包含const:分别为Fred::Fred(const Fred&)Fred& Fred::operator= (const Fred&)
  • 在构造函数中初始化对象的成员对象时,始终使用初始化列表而不是赋值。对于用户定义的类,性能差异可能很大(3倍!)。
  • 赋值运算符应确保自赋值不执行任何操作,否则可能会发生灾难。在某些情况下,这可能需要您向赋值运算符添加显式测试
  • 重载运算符时,请遵循准则。例如,在同时定义+=+的类中,a += ba = a + b通常应执行相同的操作;对于内置/固有类型的其他恒等式(例如,a += 1++ap[i]*(p+i);等等)也是如此。这可以通过使用op=形式编写二元操作来强制执行。例如:

    Fred operator+ (const Fred& a, const Fred& b)
    {
     Fred ans = a;
     ans += b;
     return ans;
    }
    

    这样,“建设性”的二元运算符甚至不需要成为友元。但有时可以更有效地实现常用操作(例如,如果class Fred实际上是std::string,并且+=必须重新分配/复制字符串内存,那么从一开始就知道最终长度可能会更好)。

为什么人们如此担心指针转换和/或引用转换?

因为它们是邪恶的!(这意味着你应该谨慎少量地使用它们。)

不知何故,程序员在使用指针类型转换时很随意。他们到处将这个转换为那个,然后奇怪为什么事情不太对劲。最糟糕的是:当编译器给出错误消息时,他们会添加一个类型转换来“让编译器闭嘴”,然后“测试”它看看它是否看起来有效。如果你有很多指针类型转换或引用类型转换,请继续阅读。

当您进行指针类型转换和/或引用类型转换时,编译器通常会保持沉默。指针类型转换(和引用类型转换)倾向于让编译器闭嘴。我把它们看作是错误消息的过滤器:编译器想要抱怨,因为它看到您正在做一些愚蠢的事情,但它也看到由于您的指针类型转换,它不被允许抱怨,所以它将错误消息丢弃到垃圾桶。这就像用胶带堵住编译器的嘴:它试图告诉您一些重要的事情,但您却故意让它闭嘴。

一个指针类型转换对编译器说:“停止思考,开始生成代码;我聪明,你笨;我大,你小;我知道我在做什么,所以假装这是汇编语言并生成代码吧。”当你开始进行类型转换时,编译器几乎是盲目地生成代码——你正在掌控(并承担!)结果的责任。编译器和语言减少(在某些情况下甚至消除!)了你对将发生什么所能获得的保证。你只能靠自己了。

打个比方,即使玩电锯合法,那也是愚蠢的。如果出了问题,别费心抱怨电锯制造商——你做了他们不保证能行的事情。你只能靠自己。

(公平地说,当您进行类型转换时,语言确实会给您一些保证,至少在有限的类型转换子集中是这样。例如,如果类型转换碰巧是从一个对象指针(指向一段数据的指针,而不是函数指针或成员指针)转换为void*类型,然后再转换回相同类型的对象指针,那么它保证会按您期望的方式工作。但在许多情况下,您只能靠自己。)

哪种标识符命名方式更好:that_look_like_this 还是 thatLookLikeThis

这是一个先例问题。如果你有Java背景,你可能使用小驼峰命名法,像这样;如果你的背景是C#,你可能使用大驼峰命名法,像这样。Ada程序员通常会使用很多下划线,Microsoft Windows/C程序员倾向于使用“匈牙利”命名法(jkuidsPrefixing vndskaIdentifiers ncqWith ksldjfTheir nmdsadType),而有Unix/C背景的人则省略所有单词中的元音字母,使用非常短的标识符名称。以及那些将所有内容限制为六个大写字母的Fortran程序员。

所以没有一个普遍的标准。如果你的项目团队对标识符命名有特定的编码规范,请使用它。但为此再发起一场战争只会带来更多争执而非启发。从商业角度来看,只有两件事重要:代码应该普遍可读,并且团队中的每个人都应该使用相同的风格。

除此之外,差异很小。

还有一件事:不要将一种编码风格引入到与平台相关的代码中,如果这种风格在该平台上显得格格不入。例如,在使用Microsoft库时显得自然的编码风格,在使用UNIX库时可能会显得怪异和随意。不要这样做。允许不同平台有不同的风格。(以防有人阅读不仔细,请不要给我发邮件讨论那些设计用于多个平台或可移植到多个平台的通用代码,因为那种代码不是平台特定的,所以上述“允许不同风格”的准则不适用。)

还有一件事。这次我是认真的。真的。不要与自动生成代码(例如,由工具生成的代码)所使用的编码风格作斗争。有些人对待编码标准带有宗教般的热情,他们试图让工具以他们的本地风格生成代码。别傻了:如果一个工具以不同的风格生成代码,不用担心。还记得金钱和时间吗?!这整个编码标准的事情本应是节省金钱和时间的;不要把它变成一个“无底洞”。

还有其他编码规范的来源吗?

是的,有好几个。

在我看来,最好的来源是Sutter 和 Alexandrescu 的《C++ 编码规范》,220 页,Addison-Wesley,2005 年,ISBN 0-321-11358-6。我有幸担任该书的顾问,作者们在激发顾问团队的积极性方面做得非常出色。每个人都以我前所未有的强度和深度进行了协作,这本书也因此变得更好。

以下是一些其他来源,您可以将其作为开发组织编码标准的起点(随机顺序)(有些已过时,有些甚至可能很糟糕;我不认可任何一个;购买者自负风险):

笔记

  • Ellemtel指南虽然过时,但因其开创性地位而被列出:它是第一个广泛分发和广泛采用的C++编码指南。它也是第一个谴责使用受保护数据的指南。
  • 《工业级C++》也已过时,但它是第一个广泛出版提及在基类中使用受保护的非虚析构函数的地方。

我应该使用“不寻常”的语法吗?

只有在有充分理由这样做时才使用。换句话说,只有当没有“正常”语法能产生相同最终结果时才使用。

软件决策应该基于金钱。除非你身处象牙塔,否则当你做某事增加了成本、增加了风险、增加了时间,或者,在受限环境中,增加了产品的空间/速度成本时,你就做了“坏事”。在你的心中,你应该把所有这些都转化为金钱。

由于这种务实、以金钱为导向的软件观念,程序员应避免使用非主流语法,只要存在等效的“正常”语法。如果程序员做了一些晦涩难懂的事情,其他程序员会感到困惑;这会花费金钱。这些其他程序员可能会引入bug(花费金钱),花费更长时间来维护(金钱),更难更改(错过市场机会=金钱),更难优化(在受限环境中,有人将不得不花钱购买更多内存、更快的CPU和/或更大的电池),并且可能导致客户不满(金钱)。这是一个风险-回报的问题:使用非正常语法带有风险,但当等效的“正常”语法也能完成相同的事情时,就没有“回报”来缓解这种风险。

例如,混淆C代码竞赛中使用的技术,礼貌地说,是非正常的。是的,它们很多都是合法的,但并非所有合法的事情都是道德的。使用奇怪的技术会迷惑其他程序员。有些程序员喜欢“炫耀”他们能将限制推到多远,但这把他们的自我置于金钱之上,这是不专业的。坦率地说,任何这样做的人都应该被解雇。(如果你认为我“刻薄”或“残忍”,我建议你调整一下态度。请记住:你的公司雇佣你是为了帮助它,而不是伤害它,任何将个人自我陶醉置于公司最佳利益之上的人都应该被解雇。)

作为一个非主流语法的例子,将?:运算符用作语句是“不正常”的。(有些人甚至不喜欢它作为表达式,但每个人都必须承认?:的使用非常多,所以它作为表达式是“正常”的,无论人们喜不喜欢。)以下是使用?:作为语句的例子:

blah();
blah();
xyz() ? foo() : bar();  // should replace with if/else
blah();
blah();

同样地,将||&&用作“if-not”和“if”语句也一样。是的,这些是Perl中的惯用法,但C++不是Perl,将它们用作if语句的替代品(而不是将它们用作表达式)在C++中就是“不正常”的。例如:

foo() || bar();  // should replace with if (!foo()) bar();
foo() && bar();  // should replace with if (foo()) bar();

这里还有一个看起来能行甚至可能合法,但肯定不正常的例子:

void f(const& MyClass x)  // use const MyClass& x instead
{
  // ...
}

使用全局变量的良好编码规范是什么?

全局变量的名称应该以 // 开头。

这是使用全局变量的理想方式:

void mycode()
{
  // do_something_with(xyz);
  ↑↑ // The leading "//" improves this global variable
}

这是一个玩笑。

有点。

事实是,在某些情况下,全局变量的危害小于其他替代方案——当全局变量是危害较小者时。但它们仍然是邪恶的。所以使用后要洗手。洗两次。

与其使用全局变量,不如认真考虑是否有办法限制变量的可见性或生命周期,如果涉及多个线程,要么将可见性限制在单个线程,要么采取其他措施来保护系统免受竞争条件的影响。

注意:如果您希望限制可见性但又不限制生命周期和线程安全,两种常见的方法是将变量移到类中作为static数据成员,或使用匿名命名空间。以下是如何将变量移到类中作为static数据成员:

class Foo {
  // ...
  static int xyz;  // See the Construct Members On First Use Idiom
  // ...
}

以下是如何使用匿名命名空间:

namespace {
  // ...
  int xyz;  // See the Construct On First Use Idiom for non-members
  // ...
}

重复:有三个主要考虑因素:可见性、生命周期和线程安全。以上示例仅解决了这三个考虑因素中的一个。