C++ 复习教程第三章(C++编码风格)

第3章 —— 编码风格


编写具有风格的代码才算真正掌握了编码;

简单地改变代码风格可以极大改变代码的外观;

不同种程序员的 C++ 代码风格有着本质的区别;

3.1良好外观的重要性

编写文体上“良好” 的代码很费时,而编写出功能分离、注释充分、结构清晰的相同程序需要更长时间,那么就有一个问题这值吗?显然,既然提出那么结果必然是值得的;


1.1事先考虑

实际中如果没有良好的编码风格会有以下问题:

  1. 对于新手不友好;
  2. 几乎无法代码维护,代价极大;
  3. 难以复用代码实现;

1.2良好风格的元素

良好代码的共通原则:

  • 文档
  • 分解
  • 命名
  • 语言的使用
  • 格式

3.2为代码编写文档

在编程环境下,文档通常指源文件中的注释。当编写相关代码时,注释用来说明你当时的想法。这里给出的信息应当是不能轻易从代码中看出来的。


2.1使用注释的原因

使用注释明显能够提高效率、易于理解代码等优点,下面给出全部使用注释的原因:

  1. 说明用途的注释:

    使用注释的原因之一是说明客户如何与代码交互。通常而言,开发人员应当能够根据函数名、返回值的类型以及参数的类型和名称来推断函数的功能。但是,代码本身不能解释一切。有时, 一个函数需要一些先置条件或后置条件,而这些需要在注释中解释。函数可能抛出的异常也应当在注释中解释。在笔者看来,只有当注释能提供有用的信息时才添加注释。因此,应由开发人员确定函数是否需要添加注释。经验丰富的程序员能可靠地确定这一点,但经验不足的开发人员则未必能做出正确的决策。因此,一些公司制定规则,要求头文件中每个公有访问的函数或方法都应该带有解释其行为的注释。某些组织喜欢将注释规范化,明确列出每个方法的目的、参数、返回值以及可能抛出的异常。

    示例:

    1
    2
    3
    4
    5
    6
    7
    /*通过注释,可用自然语言陈述在代码中无法陈述的内容。例如,在 C++中无法说明:数据库对象的saveRecord()方法只能在 openDatabaseo方法之后调用,否则将抛出异常。但可以使用注释提示这一限制,如下所示:*/

    /*
    * This method throws a ,,DatabaseNotOpenedExceptionH
    * if the openDatabase() method has not been called yet.
    */
    int saveRecord(Record& record);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /*C++语言强制要求指定方法的返回类型,但是无法说明返回值实际代表了什么。例如,saveRecord() 方法的声明可能指出这个方法返回 int 类型(这是一种不良的设计决策,见下一节的讨论),但是阅读这个声明的客户不知道 int 的含义。注释可解释其含义:*/

    /*
    * Returns: int
    * An integer representing the ID of the saved record.
    * Throws:
    * DatabaseNotOpenedException if the openDatabase() method has not
    * been called yet.
    */
    int saveRecord(Record& record);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /*如前所述,有些公司要求用正式方式记录有关函数的所有信息。下例演示了遵守这个原则的 saveRecord()方法:*/

    /*
    * saveRecord( )
    * Saves the given record to the database.
    * Parameters:
    * Records record: the record to save to the database.
    * Returns: int
    * An integer representing the ID of the saved record.
    * Throws:
    * DatabaseNotOpenedException if the openDatabase() method has not
    * been called yet.
    */
    int saveRecord(Record& record);

    /* 但不建议使用这种风格的注释。前两行完全无用,因为函数名的含义不言自明。对形参的解释也不能添加任何附加信息。*/
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /*更好的设计方式是返回 RecordID 而非普通的 int 类型,那样的话,就不需要为返回类型添加注释。RecordID 只是 int 的类型别名(见第 11 章),但传达的信息更多。唯一必须保留的注释是异常。因此,建议使用如下 saveRecord() 方法:*/


    /*
    * Throws:
    * DatabaseNotOpenedException if the openDatabase() method has not
    * been called yet.
    */
    RecordID saveRecord(Record& record);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /*有时函数的参数和返回值是泛型,可用来传递任何类型的信息。在此情况下应该清楚地用文档说明所传递的确切类型。例如,Windows 的消息处理程序接收两个参数 LPARAM 和 WPARAM, 返回 LRESUUT。这些参数和返回值可以传递任何内容,但是不能改变它们的类型。使用类型转换,可以用它们传递简单的整数,或者传递指向某个对象的指针。文档应该是这样的:*/

    /* Parameters:
    * WPARAM wParam: (WPARAM)(int): An integer representing...
    * LPARAM IParam: (LPARAM)(string*): A string pointer representing...
    * Returns: (LRESULT)(Record*)
    * nullptr in case of an error, otherwise a pointer to a Record object
    * representing ...
    */
  2. 用来说明复杂代码的注释

    在专业领域中,代码的算法往往复杂、深奥,很难理解。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    /*
    * 实现插入排序算法。该算法将数组分为两部分——有序部分和无序部分。
    * 每个元素,从位置1开始,都会被检查。数组中早于当前位置的元素都在有序部分,
    * 因此算法会将每个元素向右移动,直到找到正确的位置以插入当前元素。
    * 当算法完成对最后一个元素的操作时,整个数组都已排序。
    */
    void sort(int inArray[], size_t inSize)
    {
    // 从位置1开始,检查每个元素。
    for (size_t i = 1; i < inSize; i++)
    {
    // 循环不变式:
    // 在范围0到i-1(包括i-1)的所有元素都是有序的。
    int element = inArray[i];
    // j 标记在有序部分中,element 将要插入的位置之后。
    size_t j = i - 1;
    // 只要有序数组中的当前槽位的值大于 element,将值向右移动以为插入 element 腾出位置
    //(因此称为 "插入排序")。
    while (j >= 0 && inArray[j] > element) {
    inArray[j + 1] = inArray[j];
    j--;
    }
    // 此时有序数组中的当前位置不大于 element,因此这是 element 的新位置。
    inArray[j + 1] = element;
    }
    }

    新代码有所增长,但通过注释,使得不熟悉代码的读者也能理解这段代码;

  3. 传递元信息的注释

    使用注释的另一个原因高于代码层次提供信息,元信息提供代码的详细信息,但是不涉及代码的特定行为。例如,某组织可能想使用元信息跟踪每个方法的原始作者。还可以使用元信息引用外部文档或其他代码;

    下例给出了元信息的几个实例,包括文件的作者、创建日期、提供的特性。此外还包括表示元数据的行内注释,例如对应某行代码的 bug

    编号,提醒以后重新访问时代码中某个可能的问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
       >/*
    >* Author: marcg
    >* Date: 110412
    >* Feature: PRD version 3, Feature 5.10
    >*/

    >RecordID saveRecord(Records record)
    >{
    if (!mDatabaseOpen)
    {
    throw DatabaseNotOpenedException();
    }
    RecordID id = getDB()->saveRecord(record);
    if (id == -1) return -1; // Added to address bug #142 - jsmith 110428
    record.setld(id); // TODO: What if setld() throws an exception? - akshayr 110501
    >}
    >/*
    >* Date | Change
    >*--------+-------------------------------------------
    >* 110413 |REQ #005: <marcg> Do not normalize values.
    >* 110417 | REQ #006: <marcg> use nullptr instead of NULL.
    >*/

    警告:
    使用第 24 章讲述的源代码控制方案(也应当使用该方案), 前几个示例中就不必使用所有元信息(TODO 注释除外)。 源代码控制方案提供了带注释的修改历史,包括修改日期、修改人、对每个修改的注释(假定使用正确),以及对修改请求和 bug 报告的引用。应 当使用描述性注释,分别签入(check-in)、提交每个修改请求或 bug修复。有了这样的系统,你不必手动跟踪元信息。

    另一种元信息类型是版权声明。有些公司要求在每个源文件的开头添加此类版权信息。

    注释很容易走向极端。最好与团队成员讨论哪种类型的注释最有用,并制定约定。例如,如果团队的某个成员使用 TODO 注释表明代码仍然需要加工,但是其他人不知道这个约定,这段代码就可能被忽略。

    具体略过了,看课本吧,这太麻烦了,不是我想学的东西;


2.2注释的风格

  1. 错误:每行均注释,完全没必要,可以写很多,但是要保证有用;
  2. 正确:前置注释,某些源代码控制系统,例如(Subversion(SVN))甚至可以帮忙填写元数据;
    • 最近的修改日期
    • 原始作者
    • 前面所讲的修改日志
    • 文件给出的功能
    • 版权信息
    • 文件或类的简要说明
    • 未完成的功能
    • 已知的 bug
  3. 固定格式的注释,关于 Doxygen 的学习之后再说。
  4. 特殊注释:
    • 注释之前,思考是否可以通过修改代码来避免注释,如:重命名变量、函数与类、重新排列代码步骤的顺序,引入完好命名的中间变量;
    • 他人难以察觉的微妙之处应当注释;
    • 不要在代码中加入姓名缩写,源代码控制解决方案会自动跟踪这些信息;
    • 如果处理不太明显的 API,应当再解释 API 的地方对 API 文档进行引用;
    • 更新代码时,记得更新注释;
    • 如果使用注释将某个函数分为多节,那么考虑这个函数是否可以被分为多个更小的函数;
  5. 自文档化代码
    • 编写良好的代码并非总是需要充裕的注释,优秀的代码本身就容易阅读。
    • 如果给每行代码都加入注释,考虑是否可重写这些代码,以更好地配合注释内容。
    • 例如给函数、参数、变量等使用描述性名称。
    • 合理使用 const, 也就是说,如果不准备修改变量,就将其标记为 const
    • 重新排列函数中步骤的顺序,使人更容易理解其作用。
    • 引入命名良好的中间变量,使算法更易懂。

3.3分解(decomposition)

分解指将代码分为小段;

当有新要求或修订 bug 时,会对现有的代码进行少量修改,计算机术语 cruft 便是指的逐渐积累少量代码使得曾经优雅的代码编程一堆补丁和特例;


3.1通过重构(refactoring)分解

增强抽象的技术:

  • 封装字段:私有化字段,使用 get() 方法、set() 方法;
  • 让类型通用:创建更通用类型,便于共享代码;

分割代码使其更合理的技术:

  • 提取方法:将一个大方法的部分提取成为便于理解的方法;
  • 提取类:将现有类的部分代码转移到新类中;

增强代码名称和位置的技巧:

  • 移动方法或字段:移动到更合适的类或源文件中;
  • 重命名方法或字段:使之名称更符合其含义;
  • 上移(pull up):在 OOP 中,移到基类;
  • 下移(push down):在 OOP 中,移到派生类;

详细见课本内容,这对我目前意义不大。


3.2通过设计来分解

略。


3.3本书的分解

略。


3.4命名

编译器的几个命名规则:

  • 名称不以数字开头;

  • 包含两个下划线的名称(例如 my__name) 是保留名称,不应当使用;

    C++标准(例如C++17)规定了使用双下划线开头或结尾的标识符是保留给实现的。这意味着用户代码不应该在标识符的开头或结尾使用双下划线,以免与实现的标识符冲突。

  • 以下划线开头(例如 _Name 或 __Name) 是保留名称,不应该使用;


4.1选择恰当的名称

image-20240204084416838

如上。


4.2命名约定

  1. 计数器:【i、j】【row、column】【outerLoopIndex、innerLoopIndex】
  2. 前缀:使用前缀会使得相关代码难以维护,例如,如果某个成员变量从静态变为非静态,这意味着所有用到这个名称的地方都要修改。这通常非常耗时,因此大多数程序员不会重命名这个变量。随着代码的演变,变量的声明变了,但是名称没有变。结果是名称给出了虚假的语义,实际上这个语义是错误的。

image-20240204084721064

  1. 匈牙利表示法是关于变量和数据成员的命名约定,在 Microsc仕 Windows 程序员中很流行。其基本思想是使用更详细的前缀而不是一个字母(例如 m)表示附加信息。下面这行代码显示了匈牙利表示法的用法:

    1
    char* pszName; // psz means "pointer to a null-terminated string"

    术语“匈牙利表示法”源于其发明者 Charles Simonyi 是匈牙利人。也有人认为这准确地反映了一个事实:使用匈牙利表示法的程序好像是用外语编写的。为此,一些程序员不喜欢匈牙利表示法。本书使用前缀,而不使用匈牙利表示法。合理命名的变量不需要前缀以外的附加上下文信息,例如,用 mName 命名数据成员就足够了。

  2. get() 和 set()

  3. 大小写,统一即可

  4. 把常量放到名称空间

    假定编写一个带图形用户界面的程序。这个程序有几个菜单,包括 File、 Edit 和 Help。用常量代表每个菜单的 ID。kHelp 是代表 Help 菜单 ID 的一个好名字。

    名称 kHelp一直运行良好,直到有一天在主窗口上添加了一个 Help 按钮。还需要一个常量来代表 Help 按钮的 ID, 但是 kHelp 已经被使用了。

    在此情况下,建议将常量放到不同的名称空间中,名称空间参见第 1 章。可以创建两个名称空间: Menu 和 Button。

    每个名称空间中都有一个 kHelp 常量,其用法为 Menu::kHelp 和 Button::kHelpo

    另一个更好的方法是使用枚举器,参见第 1 章。


3.5使用具有风格的语言特征

1
2
3
4
5
i++ + ++i;
a[i] = ++i;

第一行完全没有标准,结果取决于平台;
第二行在 C++ 17 中是确定的:i先递增,再在 a[i] 中用作索引;

但它们的共同特点就是:丑陋。


5.1使用常量

1
2
3
4
5
6
7
8
#include <cmath>

double result = 2.71828 * 5.0;
std::cout << "Result: " << result << std::endl;

// 推荐的方式,使用常量 M_E
result = M_E * 5.0;
std::cout << "Result: " << result << std::endl;

5.2使用引用代替指针

C++程序员通常开始学的是 C。在 C 中,指针是按引用传递的唯一机制,多年来一直运行良好。在某些情况下仍然需要指针,但在许多情况下可以用引用代替指针。如果开始学习的是 C, 可能认为引用实际上没有给C++语言增加新的功能,只是引入了一种新的语法,其功能己经由指针提供。

用引用替换指针有许多好处。首先,引用比指针安全,因为引用不会直接处理内存地址,也不会是 nullptr。其次,引用在文体上比指针好,因为引用使用与堆栈变量相同的语法,没有使用 * 和&等符号。引用易于使用,因此将引用加入风格中没有任何问题。遗憾的是,某些程序员认为,如果在函数调用中看到&,被调用的函数将改变对象;如果没有看到&,对象一定是按值传递。而使用引用,就无法判断函数是否将改变对象,除非看到函数原型。这种思维方式是错误的。用指针传递未必意味着对象将改变,因为参数可能是 const T。传递指针或引用是否会修改对象,都取决于函数原型是否使用了 const T* 、T*、constT&或 T&。因此,只有查看函数原型,才能判断函数是否改变对象

使用引用的另一个好处是它明确了内存的所有权。如果一个程序员编写了一个方法,另一个程序员传递给它一个对象的引用,很明显可以读取并修改这个对象,但是无法轻易地释放对象的内存。如果传递的是一个指针,就不那么明显。需要删除对象来清理内存吗?还是调用者需要这样做?处理内存的较好方法是使用第 1 章介绍的智能指针.


5.3使用自定义异常

C++可以很方便地忽略异常,这一语言的语法没有强制处理异常,可以很方便地用传统的机制(例如返回nullptr 或者设置错误标志)编写容错程序。

异常提供了更丰富的错误处理机制,自定义异常允许根据需要进行取舍。例如,Web 浏览器的自定义异常类型包含的字段可指定包含错误的页面、错误发生时的网络状态和附加的环境信息。

第 14 章将详细讲述 C++中的异常 。


3.6格式

一句话,统一的就是好的。


6.1关于大括号对齐的争论

1
2
3
4
Staff
{
...
}

我都是这样用的;


6.2关于空格和圆括号的争论

1
2
3
function();
if ()
for ()

函数不空格,判定循环语句空格;


6.3空格和制表符

没啥说的;


3.7风格的挑战

许多程序员在项目开始时都保证他们将做好每件事。只要变量或参数永远不变,就将其标记为 const。所有变量都具有清楚的、简明的、容易阅读的名称。每个开发人员都将左大括号放在后续行,采用标准文本编辑器,并遵循关于制表符和空格的约定。

维持这种层次的格式一致非常困难,原因有很多。当涉及 const 时,有些程序员不知道如何用它。总会遇到不支持 const 的旧代码或库函数。好的程序员会使用 const_cast 暂时取消变量的 const 属性,但缺少经验的程序员会取消来自调用函数的 const 属性,结果,程序从不使用 const。

有时,标准化的格式会与程序员的个人口味和偏好发生冲突。或许团队文化无法强制使用严格的风格准则。此类情况下,必须判断哪些元素需要标准化(例如变量名称和制表符),哪些元素可以由个人决定其风格(或许空格和注释格式可以这样)。甚至可以获取或编写脚本,自动纠正格式 bug, 或将格式问题与代码错误一起标记。一些开发环境,例如 Microsoft Visual C++ 2013, 支持根据指定的规则自动格式化代码,这样就很容易编写出始终遵循指定规则的代码


3.8本章小结

一句话,风格好就是NB。


C++ 复习教程第三章(C++编码风格)
http://example.com/2024/03/14/C++ 复习教程第三章(C++编码风格)/
作者
yanhuigang
发布于
2024年3月14日
许可协议