C Programming Language

Practice #04: Develop Good Coding Style and Programming Habits

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-01-01


前置知识

在阅读本文档前,请先阅读本教程的整体介绍,详见 Introduction


前言

作为一名初学者,在努力学习编程相关知识的同时,你还需要注重让自己写出来的程序具有良好的代码风格,也需要注意养成良好的编程习惯,让自己写出来的程序。好的代码风格和编程习惯都是极其重要的,不幸的是,一些人很快就注意到了这一点,而更多的人却直到很久以后才会幡然醒悟。

如果你是一位学生,那么只要你的老师神智正常,就一定会时不时地向你强调这些问题,但却很可能未曾把问题说清楚。

究竟什么样的代码风格是好的?什么样的编程习惯是好的?它们重要,但是究竟为什么重要?其实这些问题,即便你不专门去了解也迟早会领悟到,但如果你在初学阶段未曾知晓,那你将来很有可能因为糟糕的编程习惯和代码风格而大量吃亏,方才醒悟过来。本文正是为那些对此一无所知、或是一知半解的读者准备的。

本文中的内容更多的是我个人的经验和体会,并非是硬性的知识,你应当保持自己的主观判断,不可生搬硬套,吸收其中适合自己的内容即可。

因为我本人在最初入门编程的时候,缺乏老师或前辈的指点,也缺乏参考资料和书籍,所以我曾经吃了不少编程习惯和代码风格的亏(虽然我那时的习惯并不太糟糕,但也不怎么好,即便如此尚且吃了很多亏),因此我可能对此的理解也较为透彻,本文应该能对读者提供不小的帮助。


养成良好的代码风格

代码风格的重要性

良好的代码风格是十分重要的,概括地讲,它可以使你的程序:

  1. 具有良好的可读性;

  2. 具有较好的容错性;

  3. 易于修改和扩展。

我们先对上述内容进行一个概括性的描述,下文中会给出大量的实例来向读者展示糟糕和良好的代码风格之间的区别,并借此让读者深刻理解代码风格的重要性。

良好的可读性

对于初学者而言,最容易接触到的就是可读性的问题。

一份风格良好的代码是结构清晰的、一目了然的,尤其是对于简单的玩具级代码而言。即便是复杂的程序,代码的阅读者也能花费较小的代价理清整个程序的逻辑结构,并且程序中的各个子模块的内容都较为清晰易懂。相反,一份风格糟糕的代码可能会让人一眼扫过去就失去继续阅读的欲望。

可读性是非常重要的,尤其是对于初学者而言更为重要,不仅仅是代码看起来美观这种肤浅的理由。比如,当你的代码出现了问题而你费尽心思也解决不了时,你可能会向他人求助,可读性强的代码可以帮助别人迅速地读懂你的代码,极大提高解决问题的效率。相反,如果你的代码可读性太差,别人很可能会失去阅读的欲望,进而对你敷衍了事或是直接拒绝提供帮助,尤其是在线上进行求助时。比如对于用 QQ 找我帮忙 DEBUG 的,如果直接复制粘贴一大坨没有缩进的代码给我,且与我关系不熟,我有很大的概率会直接忽略,因为我认为他这是在浪费我的时间。

写出可读性强的代码不仅是为了让别人看,也是为了让自己看。如果你的代码可读性很差,你自己现在看着是没问题,等过几个月,或者仅仅几个星期,再把这段代码翻出来给你,可能你自己都看不懂,这就可能给你自己带来很大的麻烦。

另一方面,当你将来进行团队合作开发时,在需要多人共同维护同一段代码时,如果你的代码风格很糟糕,那么你在你的共事者心目中的印象一定会变得很差。哪怕是由你自己独立控制的代码,如果以后改由他人接手,那你肯定会被人在背后骂得不停地打喷嚏。

较好的容错性

容错性也是一个十分重要的原因。我所说的容错性包含两个方面。

其一,可以降低因为你在写代码时笔误而导致程序出错的可能性。

比如说,风格良好的代码总是能避免对同一数据存放多个无关的副本。这听起来有些抽象,我们举一个最简单的例子,当你需要多个长度相同的数组时,下面的代码的风格就有些不太好:

int data[1000000], id[1000000], sum[1000000], son[2][1000000];
double value[1000000];

如果你不幸笔误,并因此导致后面访问数组时发生内存溢出,你可能很难查出问题所在:

int data[1000000], id[1000000], sum[100000], son[2][1000000];
double value[1000000];

其二,可以提高与用户交互时的容错性。

如果你只是写一些玩具级的代码,那么第二点对你来说可能并不重要,但在任何实际程序中这都是至关重要的。

无论是你现在写的简单的编程作业题,还是你以后会遇到的各种机试或程序竞赛中的编程题,“用户”(即评测机)输入的内容都是被规定好的,说是数字就是数字,说传入的指针非空就是非空,但在实际开发中,这些限制条件其实很少能被满足。

风格良好的代码,应当能够处理来自用户的错误输入,能够处理接口中接收到的各种错误参数,等等,至少要能够处理常见的大部分问题。而风格糟糕的代码总是忽略这些问题,结果被调皮的用户炸出各种轻重不一的 BUG ,或是被接口中传来的错误参数直接把整个程序搞崩溃。

易于修改和扩展

其一是易于修改。

其实这一点和容错性问题的第一点有很大部分的重叠。还是用那个数组的例子,当你需要修改这些数组的长度时,你不得不修改五个地方,对于这个例子而言可能还好,如果更多的话,一旦你改漏了一个地方,你可能很难查出问题所在,因为在你的意识中你已经修改过数组的长度了。下文中会给出更详细的例子。

其二是易于扩展。

风格良好的代码总是会考虑到将来可能会对程序进行的功能扩展,并为此做出一定的准备,当你需要扩展这样的程序时就会感到较为舒适。这对初学者而言可能较难理解,你只需要先知晓此事即可。

做好代码缩进

单就代码可读性而言,做好代码缩进是最为重要的。本节我们使用简单的选择排序作为示例程序。

我们先考虑一个最极端的例子:

#include <stdio.h>
int main(void) {
int a[100];
int n, i, j, m, t;
scanf("%d", &n);
for (i = 0; i < n; i ++)
scanf("%d", &a[i]);
for (i = 0; i < n; i ++) {
m = i;
for (j = i + 1; j < n; j ++)
if (a[m] > a[j])
m = j;
}
t = a[i];
a[i] = a[m];
a[m] = t;
for (i = 0; i < n; i ++) {
printf("%d", a[i]);
printf(i == n - 1 ? "\n" : " ");
}
return 0;
}

如果你面对这样的代码,你是否愿意仔细阅读?如果你不得不阅读,那么你需要耗费多少精力去搞清楚这段代码功能,又要耗费多少精力去找到其中的错误?

现在我们做好代码缩进,现在一些低级错误就显得一目了然了,甚至如果你一开始写的时候就做好缩进,很多低级错误根本就不会犯下:

#include <stdio.h>
 
int main(void) {
    int a[100];
    int n, i, j, m, t;
    scanf("%d", &n);
    for (i = 0; i < n; i ++)
        scanf("%d", &a[i]);
    for (i = 0; i < n; i ++) {
        m = i;
        for (j = i + 1; j < n; j ++)
            if (a[m] > a[j])
                m = j;
    }
    t = a[i];
    a[i] = a[m];
    a[m] = t;
    for (i = 0; i < n; i ++) {
        printf("%d", a[i]);
        printf(i == n - 1 ? "\n" : " ");
    }
    return 0;
}

读者可以自行感受一下其中的差距,阅读或是 DEBUG 这两份内容一样但缩进不同的代码的效率显然是不同的。

实际上,多数不注意代码缩进的人也不会搞得这么极端,情况最糟糕的代码可能会像下面这样:

#include <stdio.h>
 
int main(void) {
    int a[100];
    int n, i, j, m, t;
    scanf("%d", &n);
    for (i = 0; i < n; i ++)
    {
        scanf("%d", &a[i]); }
        for (i = 0; i < n; i ++) {
            m = i;
        for (j = i + 1; j < n; j ++)
            if (a[m] > a[j]) {
            m = j;
        }
            t = a[i];
                a[i] = a[m];
                a[m] = t;
}
 
for (i = 0; i < n; i ++) {
printf("%d", a[i]);
    printf(i == n - 1 ? "\n" : " ");
}
    return 0;
}

事实上,有时这比极端情况还要令人头疼,代码简直看起来是被人施加了障眼法一样。

现在我们做好代码缩进,读者仍然可以自行感受一下其中的差距:

#include <stdio.h>
 
int main(void) {
    int a[100];
    int n, i, j, m, t;
    scanf("%d", &n);
    for (i = 0; i < n; i ++) {
        scanf("%d", &a[i]);
    }
    for (i = 0; i < n; i ++) {
        m = i;
        for (j = i + 1; j < n; j ++)
            if (a[m] > a[j]) {
                m = j;
            }
        t = a[i];
        a[i] = a[m];
        a[m] = t;
    }
    for (i = 0; i < n; i ++) {
        printf("%d", a[i]);
        printf(i == n - 1 ? "\n" : " ");
    }
    return 0;
}

因此,请你养成做好代码缩进的习惯,不仅是为了给别人看,这对你自己写代码和 DEBUG 的效率也是有很大提升的。

不要滥用临时变量

少数初学者会写出令人感到匪夷所思的代码,我们仍然以选择排序为例:

#include <stdio.h>
 
int main(void) {
    int a[100];
    int n;
    int i, j, k, l;
    int m, t;
    scanf("%d", &n);
    for (i = 0; i < n; i ++) {
        scanf("%d", &a[i]);
    }
    for (j = 0; j < n; j ++) {
        m = j;
        for (k = j + 1; k < n; k ++) {
            if (a[m] > a[k]) {
                m = k;
            }
        }
        t = a[j];
        a[j] = a[m];
        a[m] = t;
    }
    for (l = 0; l < n; l ++) {
        printf("%d", a[l]);
        printf(l == n - 1 ? "\n" : " ");
    }
    return 0;
}

我怀疑是不是这些人误以为临时变量不能反复使用才写出这样的代码,非得给每个循环都用不同的临时变量。这样的代码有多恶心就不必我多说了吧,如果有十几个互不嵌套的循环,这临时变量是不是得开一长串?

一般而言,循环内最多同时使用多少个临时变量、我们就开多少个临时变量。通常不会超过 33 个(仅指用作指示下标的那些),也就是 i, j, ki,\ j,\ k ,否则我们会考虑将内层循环包装成函数(具体详见下文),嵌套层数过多的循环也会极大地降低代码可读性。

不要滥用switch/case

我们以一个简单程序为例,该程序拥有统计字符串中每种数字字符的出现次数:

#include <stdio.h>
#include <string.h>
 
int main(void) {
    int result[10] = {0};
    char str[100];
    int n;
    int i;
    scanf("%s", str);
    n = strlen(str);
    for (i = 0; i < n; i ++) {
        switch (str[i]) {
        case '0': result[0] ++; break;
        case '1': result[1] ++; break;
        case '2': result[2] ++; break;
        case '3': result[3] ++; break;
        case '4': result[4] ++; break;
        case '5': result[5] ++; break;
        case '6': result[6] ++; break;
        case '7': result[7] ++; break;
        case '8': result[8] ++; break;
        case '9': result[9] ++; break;
        }
    }
    for (i = 0; i <= 9; i ++) {
        printf("%d : %d\n", i, result[i]);
    }
    return 0;
}

显然这是一份非常糟糕的代码。如果要统计一个数组里每种一百以内的整数出现的次数,你是不是要复制粘贴一百行 case 语句啊?

不要因为懒得思考就毫无风度地复制粘贴 case 语句。当你需要使用多个重复性极强的 case 语句时,就应当思考其逻辑关联性,并对代码进行简化。显然数字和数字字符之间有一个 '0' 的差距,那么我们只需要一行代码即可处理所有情况:

for (i = 0; i < n; i ++) {
    if (str[i] >= '0' && str[i] <= '9') {
        result[str[i] - '0'] ++;
    }
}

再例如根据月份获取当月天数,这可能有些复杂,一些初学者可能想不到。我们可以用空间换时间,用一个数组来记录每个月的天数,形成从月份到天数的映射:

int day_of_month[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

这样我们可以避免用 case 或 if 语句穷举,造成大量代码冗余。如果涉及闰年,再另外特殊处理就可以了。

综上所述,写代码时要注意思考,只能用 case 或 if 大量穷举而没有任何办法精简代码的情形较为苛刻,至少初学者是遇不到的。

使用语义明确的标识符名称

为了说明这个问题,我们用一道简单的编程题作为例子:

题目大意:

nn 家店铺在 mm 个月内的总净收入。会给出每家店在每个月的收入和支出,给出税率,规则是对净收入收税,如果亏本则不收税。

输入格式:

首先输入两个正整数 nnmm ,分别表示店铺个数和月份数。

接下来 nn 行,第 ii 行输入 2m2m 个正整数,第 2j1, 2j2j-1,\ 2j 个数 aij, bija_{ij},\ b_{ij} 分别表示第 ii 家店在第 jj 个月的收入和支出。

最后输入一个小数 pp 表示税率。

数据范围:

  • 1n, m201 \le n,\ m \le 20

  • 1aij, bij100001 \le a_{ij},\ b_{ij} \le 10000

  • 0<p<10 < p < 1

问题很简单,按照题意实现即可。

现在我们看下面这份代码:

#include <stdio.h>
 
int main(void) {
    int n, m, i, j, a[20][20], b[20][20];
    double p, r, t;
    scanf("%d %d", &n, &m);
    for (i = 0; i < n; i ++) {
        for (j = 0; j < m; j ++) {
            scanf("%d %d", &a[i][j], &b[i][j]);
        }
    }
    scanf("%lf", &p);
    r = 0;
    for (i = 0; i < n; i ++) {
        for (j = 0; j < m; j ++) {
            t = a[i][j] - b[i][j];
            if (t > 0) {
                r += t * (1 - p);
            } else {
                r += t;
            }
        }
    }
    printf("%.2f\n", r);
    return 0;
}

毫无疑问,这份代码是正确的。如果是在机试或是竞赛中,这样写代码去解这道编程题也是挑不出毛病的,因为在这样的情况下,以最短的时间完成任务是最重要的。然而,如果这是一个中小型 APP 甚至是大型软件系统中的一个子任务,以这样的风格写代码是绝对不可取的。

不妨想象一下,三天之后你再打开这份你亲手写下的代码,在不看题目的前提下,你能知道自己在写些什么吗?比如此时需求发生了变更,我要分别求每家店铺在 mm 个月内的总净收入,而不是所有店铺之和,那么你就不得不去查询上层调用程序的输入规则,来搞清楚自己这些 a, b, c, pa,\ b,\ c,\ p 都是什么东西。

你应当养成习惯,在给变量、函数或是自定义符合类型起名时,使用语义明确的名称。对于上例而言,我们可以这样:

#include <stdio.h>
 
int main(void) {
    int n_shop, n_month;
    int income[20][20], expenditure[20][20];
    double tax_rate;
    double sum_net_income;
    int i, j, t;
    scanf("%d %d", &n_shop, &n_month);
    for (i = 0; i < n_shop; i ++) {
        for (j = 0; j < n_month; j ++) {
            scanf("%d %d", &income[i][j], &expenditure[i][j]);
        }
    }
    scanf("%lf", &tax_rate);
    sum_net_income = 0;
    for (i = 0; i < n_shop; i ++) {
        for (j = 0; j < n_month; j ++) {
            t = income[i][j] - expenditure[i][j];
            if (t > 0) {
                sum_net_income += t * (1 - tax_rate);
            } else {
                sum_net_income += t;
            }
        }
    }
    printf("%.2f\n", sum_net_income);
    return 0;
}

现在,无论过去多久,当你打开这份代码,你只需要很短的时间就能看明白自己当初写了什么。

你也许会觉得这样冗长的变量名会很影响你写代码的速度,而且代码看起来也显得冗余。事实的确如此,但相比之下,代码的可读性和易于维护是更加重要的。我之前也特地强调过,如果当前更重视以最短的时间完成任务,或是你只是写一份一次性使用的代码,那么使用语义明确的名称反而是增添累赘。

因为这道题很简单,你可能还觉得没什么,那么看看下面这份代码吧。如果你需要使用这个计算某种东西的程序,我想,即便它的作者亲自向你逐行解释,你也不得不拿个小本本把各个变量的作用全部记下来,否则你转头就会忘个精光,等到你想要修改某些参数的时候,事情就麻烦了。

#include <stdio.h>
 
int main(void) {
    int a, b, c, d[20], e[20], f[20], g[20], n, m, p, q, r, i, j;
    scanf("%d %d %d", &a, &b, &c);
    scanf("%d %d", &n, &m);
    for (i = 0; i < n; i ++) {
        scanf("%d %d", &d[i], &e[i]);
    }
    for (i = 0; i < m; i ++) {
        scanf("%d", &f[i]);
    }
    for (i = 0; i < n; i ++) {
        for (j = 0; j < m; j ++) {
            g[i] += (a * d[i] + (b - c) / e[i]) * f[j];
        }
        p = 0;
        q = 1;
        for (j = 0; j < m; j ++) {
            p += f[j];
            q *= f[j];
        }
        g[i] *= (p + q);
    }
    r = 0;
    for (i = 0; i < n; i ++) {
        r += g[i] * (d[n - i - 1] - e[n - i - 1]);
    }
    printf("%d\n", r);
    return 0;
}

虽然这只是一个我胡编乱造的例子,但是实际开发中绝对不乏这种计算过程复杂的东西,如果大家都胡乱给变量起名,那可真的堪称是程序员的互相坑害。

struct AAA {
    int you;
    double dont;
    int know;
    int what;
    short this;
    short is;
    char hahaha;
};
 
void foo(struct AAA aaa) {
    // ...
}

因此请你谨记:只有当你写的程序非常简单,或是你写的程序是一次性用品(例如机试或竞赛)时,你才可以用 a, b, c, p, q, ra,\ b,\ c,\ p,\ q,\ r 这种东西作为变量名,用 f, g, h, test, foof,\ g,\ h,\ test,\ foo 这种东西作为函数名,用 A, B, CA,\ B,\ C 这种东西作为自定义类型的名称,否则一定要使用语义明确的标识符名称。

使用统一的命名习惯

为了保障代码的可读性,也为了避免你看起来像是个笨拙的菜鸟,我们一般建议你在一个程序中使用统一的命名习惯。例如:

下划线命名法:

int n_shop, n_month;
int income[20][20], expenditure[20][20];
double rate_tax;
double sum_net_income;
int i, j, t;

驼峰命名法:

int nShop, nMonth;
int income[20][20], expenditure[20][20];
double rateTax;
double sumNetIncome;
int i, j, t;

不同的编程语言往往有不同的命名习俗,例如 C/C++ 语言通常对变量名和函数名使用下划线命名法、对自定义类型名使用下划线或帕斯卡命名法,而 Java 语言则通常对变量名和函数名使用驼峰命名法、对自定义类型名使用帕斯卡命名法。建议初学者入乡随俗即可,与大环境统一总是百利而无一害的。

具体关于 C 语言的命名习俗就不在此赘述了,详见 C 语言的 01 - Convention of Naming Identifier 文档。

当然,如果你需要进行团队合作,那么除了编程语言的习俗,更应当注重与你的团队保持相同的命名习惯。

可能存在一些关于命名法孰优孰劣的论战,我个人认为,只要命名统一,使用何种命名习惯并不重要。

使用统一的大括号换行习惯

关于两种大括号换行习惯孰优孰劣的论战已经持续了几十年:

// 习惯 A
for (i = 0; i < n; i ++) {
    foo(x, i);
    if (i == n / 2) {
        y = f(i);
        printf("%d\n", y);
    }
}
// 习惯 B
for (i = 0; i < n; i ++)
{
    foo(x, i);
    if (i == n / 2)
    {
        y = f(i);
        printf("%d\n", y);
    }
}

当然,这种论战没什么意义,你喜欢怎么换行就怎么换行,但是请在你的代码中保持使用一种风格。无论你使用何种习惯,都不会有人对你有意见,但是如果你混着用就显得很蠢了:

for (i = 0; i < n; i ++) {
    foo(x, i);
    if (i == n / 2)
    {
        y = f(i);
        printf("%d\n", y);
    }
}

的确,这其实也没什么,但如果你不觉得丑,也不介意时不时被人喷的话……那请自便……这的确只是个小问题,不足为虑,只是单纯地丑而已。

勤加注释

给自己的程序加上注释是非常重要的,这能极大地提高程序的可读性,有利于别人快速理解你的代码。

我们仍然使用之前的例子,感受一下加上注释的程序:

#include <stdio.h>
 
int main(void) {
    // 变量声明
    int n_shop, n_month;
    int income[20][20], expenditure[20][20];
    double tax_rate;
    double sum_net_income;
    int i, j, t;
    // 输入
    scanf("%d %d", &n_shop, &n_month);
    for (i = 0; i < n_shop; i ++) {
        for (j = 0; j < n_month; j ++) {
            scanf("%d %d", &income[i][j], &expenditure[i][j]);
        }
    }
    scanf("%lf", &tax_rate);
    // 求解
    sum_net_income = 0;
    for (i = 0; i < n_shop; i ++) {
        for (j = 0; j < n_month; j ++) {
            t = income[i][j] - expenditure[i][j];
            if (t > 0) {
                sum_net_income += t * (1 - tax_rate);
            } else {
                sum_net_income += t;
            }
        }
    }
    // 输出
    printf("%.2f\n", sum_net_income);
    // 返回
    return 0;
}

现在,我们一眼扫过去就知道每段代码是做什么的,阅读时可以快速定位到自己想看的地方。

当然,我们还可以打上更多的注释:

#include <stdio.h>
 
int main(void) {
    // 变量声明
    // n_shop : 店铺总数
    // n_month : 月份总数
    // income : income[i][j] 表示第 i 家店在第 j 个月的收入
    // expenditure : expenditure[i][j] 表示第 i 家店在第 j 个月的支出
    // tax_rate : 税率,对净收入生效
    // sum_net_income : 税后收入的总和
    // i, j, t : 临时变量
    int n_shop, n_month;
    int income[20][20], expenditure[20][20];
    double tax_rate;
    double sum_net_income;
    int i, j, t;
 
    // 输入
    scanf("%d %d", &n_shop, &n_month);
    for (i = 0; i < n_shop; i ++) {
        for (j = 0; j < n_month; j ++) {
            scanf("%d %d", &income[i][j], &expenditure[i][j]);
        }
    }
    scanf("%lf", &tax_rate);
 
    // 求解
    // 初始化答案值
    sum_net_income = 0;
    // 遍历每个店铺
    for (i = 0; i < n_shop; i ++) {
        // 对每个店铺遍历每个月份
        for (j = 0; j < n_month; j ++) {
            // 计算净收入
            t = income[i][j] - expenditure[i][j];
            // 对正的净收入计算税收并对答案求和
            if (t > 0) {
                sum_net_income += t * (1 - tax_rate);
            } else {
                sum_net_income += t;
            }
        }
    }
 
    // 输出
    printf("%.2f\n", sum_net_income);
 
    // 返回
    return 0;
}

对于这种简单的例子而言,注释的重要性可能还不太突出,但是在大型程序中通常有成千上万行代码,如果没有注释,那简直就是巨大的灾难,几乎不会有人愿意阅读这样的程序。

作为初学者,你可能还只会写一些玩具级代码,因此也不需要打很多注释,但是请养成这样的习惯,至少应该像第一个例子那样打上一些简单的注释,使自己程序的逻辑结构显得清晰明了。

善用函数和结构体

善用函数

当代码量多到一定程度时,把所有的代码都放在 main 函数里显然是不切实际的,因此你需要学会善用函数,尽可能地将功能不同的代码分别包装,不要把大量的代码塞在同一个函数里。

这样做有两方面的好处。

其一是可读性的提升。这就像注释一样,阅读者可以很快地找到他想找的部分,尤其是在大型程序中。毕竟,当代码很多时,即便用注释分割也是不足的,而且许多 IDE 都提供了快速定位到指定函数的位置的功能。

其二是可维护性的提升和代码的精简。考虑一个简单的例子,你实现了选择排序算法,但是你的程序中需要多次对不同的数组进行排序,如果不使用函数,你的代码可能会像下面这样:

#include <stdio.h>
 
int main(void) {
    // 变量声明
    int a[20], size_a;
    int b[50], size_b;
    int c[15], size_c;
    int i, j;
    int m, t;
 
    // ...
    // 一些代码...
    // ...
 
    // 排序
    for (i = 0; i < size_a; i ++) {
        m = i;
        for (j = i + 1; j < size_a; j ++) {
            if (a[m] > a[j]) {
                m = j;
            }
        }
        t = a[i];
        a[i] = a[m];
        a[m] = t;
    }
 
    // ...
    // 一些代码...
    // ...
 
    // 排序
    for (i = 0; i < size_b; i ++) {
        m = i;
        for (j = i + 1; j < size_b; j ++) {
            if (b[m] > b[j]) {
                m = j;
            }
        }
        t = b[i];
        b[i] = b[m];
        b[m] = t;
    }
 
    // ...
    // 一些代码...
    // ...
 
    // 排序
    for (i = 0; i < size_c; i ++) {
        m = i;
        for (j = i + 1; j < size_c; j ++) {
            if (c[m] > c[j]) {
                m = j;
            }
        }
        t = c[i];
        c[i] = c[m];
        c[m] = t;
    }
 
    // ...
    // 一些代码...
    // ...
 
    // 返回
    return 0;
}

这会带来很多问题。其一,这三段排序代码中,除了数组和数组长度这两个变量不同以外,其他内容完全一样,这显然是一种代码冗余。其二,如果你在复制粘贴排序代码并修改变量名时漏改了哪个变量,将会给自己留下隐患,且较难排查。其三,如果你发现你的排序算法中有一处小错误,你不得不对每一段排序代码纠错,期间一旦遗漏了哪一个,同样会留下难以排查的错误,因为你会认为自己已经修正了这个错误。或者,如果你希望修改或是扩展这个算法,也会面临同样的问题。

当我们将选择排序包装为函数后,一切都解决了:

#include <stdio.h>
 
void select_sort(int arr[], int n) {
    int i, j;
    int m, t;
    for (i = 0; i < n; i ++) {
        m = i;
        for (j = i + 1; j < n; j ++) {
            if (arr[m] > arr[j]) {
                m = j;
            }
        }
        t = arr[i];
        arr[i] = arr[m];
        arr[m] = t;
    }
}
 
int main(void) {
    // 变量声明
    int a[20], size_a;
    int b[50], size_b;
    int c[15], size_c;
 
    // ...
    // 一些代码...
    // ...
 
    // 排序
    select_sort(a, size_a);
 
    // ...
    // 一些代码...
    // ...
 
    // 排序
    select_sort(b, size_b);
 
    // ...
    // 一些代码...
    // ...
 
    // 排序
    select_sort(c, size_c);
 
    // ...
    // 一些代码...
    // ...
 
    // 返回
    return 0;
}

现在,一旦发现代码中有错误,我们可以轻易地改正,而不用担心这段代码在哪里被使用、被使用了多少次。我们还可以轻易地对函数的功能进行扩展,例如指定对数组中的特定区间进行排序,还能指定是从小到大还是从大到小排序,等等。事实上,标准库中的排序就提供了这些功能。

这只是一个简单的例子,在实际开发中,学会包装函数是尤为重要的基本功。如果你写出一个代码几百行的超巨大函数,别人不会觉得你很厉害,只会觉得你很蠢——除非真的有那么一个非常单一的功能,实现它就是需要这么复杂的计算,并且这个计算过程完全没办法划分成几个子过程,但我想这种情况是极其稀少的,至少我至今还没遇到过。

善用结构体

你可能会做到一些作业题,让你处理什么学生信息或者图书信息之类的,并且会建议你使用结构体,但是有可能并没有告诉你为什么要使用:

// 使用结构体
struct Student {
    char name[20];
    int id;
    int age;
    int grade;
};
struct Student students[50];
// 不使用结构体
char name[50][20];
int id[50];
int age[50];
int grade[50];

其一,包装成结构体在很多时候会方便得多,例如在传递函数参数的时候:

// 使用结构体
void f(struct Student students[]);
// 不使用结构体
void f(char name[][20], int id[], int age[], int grade[]);

其二,包装成结构体可以解决命名冲突问题,例如你同时还要处理教师信息的时候:

// 使用结构体
struct Student {
    char name[20];
    int id;
    int age;
    int grade;
};
struct Teacher {
    char name[20];
    int id;
    int age;
    int subject;
}
struct Student students[50];
struct Teacher teachers[30];
// 不使用结构体
char student_name[50][20];
int student_id[50];
int student_age[50];
int student_grade[50];
char teacher_name[30][20];
int teacher_id[30];
int teacher_age[30];
int teacher_subject[30];

其三,和使用函数的一个理由类似,当你打算修改学生信息条目的时候,比如添加性别信息,那么如果你使用结构体,可以减少很多修改工作。

可见使用结构体对代码精简是很有益的,尤其是程序较为复杂的时候。对于同类型的信息都应该将其包装成结构体。

在布尔表达式中勤加括号

虽然 C 语言明确规定了各个运算符的优先级和结合顺序,但是没有人想在这种事情上浪费时间,请看下面的代码:

if (a == 0 || b == 1 && c == 2 || d == 3 || e == 4 && f == 5) {
    // ...
}

我想,你看到这段代码的第一反应恐怕不是去思考运算符优先级和结合顺序的问题,而是想把写代码的人打一顿,因此请你自己也不要写这种代码。即便优先级都是定好的,只要背诵优先级就能很快理解这段代码,你也应当在上面加好括号:

(a == 0 || (b == 1 && c == 2) || d == 3 || (e == 4 && f == 5)) {
    // ...
}

或许只是 ||&& 可能还好,如果是其他一些相对比较少见的运算符,不加括号才是真的恶心人。相信我,没多少人会喜欢背诵运算符优先级表。

避免对同一数据存放多个无关副本

前文已经提到过,风格良好的代码总是能避免对同一数据存放多个无关的副本。这听起来有些抽象,我们举一个最简单的例子,当你需要多个长度相同的数组时,下面的代码的风格就有些不太好:

int data[1000000], id[1000000], sum[1000000], son[2][1000000];
double value[1000000];

如果你不幸笔误,并因此导致后面访问数组时发生内存溢出,你可能很难查出问题所在:

int data[1000000], id[1000000], sum[100000], son[2][1000000];
double value[1000000];

因此,对于同一份数据,我们应当尽量只存放一个副本。例如:

#define N 1000000
int data[N], id[N], sum[N], son[2][N];
double value[N];

现在,你只需要修改宏定义 NN 的值,就能同时改变所有数组的大小。

当然,很多时候我们没法只存放同一个副本,此时就需要让这些副本之间通过程序关联起来,并注意处理及时更新的问题,等等。这不是初学者需要考虑的问题,在玩具级程序中不会遇到这种问题,此处不再赘述。

处理简单的错误输入

前文已经提到过,风格良好的代码,应当能够处理来自用户的错误输入,能够处理接口中接收到的各种错误参数,等等,至少要能够处理常见的大部分问题。

还是以前文的那道计算收入的题为例,如果你不是在面对机试或是竞赛,而是在编写一个软件系统中的子功能,你就应该考虑用户输入是否正确,并加以处理,例如:

for (i = 0; i < n_shop; i ++) {
    for (j = 0; j < n_month; j ++) {
        scanf("%d %d", &income[i][j], &expenditure[i][j]);
        while (income[i][j] < 0 || expenditure[i][j] < 0) {
            printf("Illegal input for income/expenditure of shop %d, month %d! Please input again.\n", i, j);
            scanf("%d %d", &income[i][j], &expenditure[i][j]);
        }
    }
}

也可以这样:

for (i = 0; i < n_shop; i ++) {
    for (j = 0; j < n_month; j ++) {
        scanf("%d %d", &income[i][j], &expenditure[i][j]);
        if (income[i][j] < 0 || expenditure[i][j] < 0) {
            printf("Illegal input for income/expenditure of shop %d, month %d!\n", i, j);
            exit(-1);
        }
    }
}

这只是举个例子,还可以使用 assertassert 等方法,实际究竟怎么处理还是要具体情况具体分析。

当你在实现一个函数时,如果必要的话,也需要考虑这些问题,例如一个简单的 swapswap 函数:

void swap(int * x, int * y) {
    int t;
    if (x == NULL || y == NULL) {
        printf("Error swap NULL pointer!\n");
        exit(-1);
    }
    t = *x;
    *x = *y;
    *y = t;
}

当然,如你所见,我们只能处理空指针,而无法处理野指针,没有任何办法能判断一个指针是否为野指针,这方面我们只能寄希望于调用这个函数的代码的编写者了。程序员不可能考虑到所有的错误,也不可能处理得了所有的错误,做到尽可能好即可。


养成良好的编程习惯

编程习惯的重要性

相对于代码风格而言,编程习惯就没有那么地重要了。养成好的编程习惯只是为了自己舒服,提高编程效率、降低出错概率等等。

关注变量初始化

养成习惯:对于任何一个新添加的变量,总是关注它是否有被合法地初始化。

关注问题的边界条件

养成习惯:在解决问题、实现接口或函数时,总是关注边界条件。例如:

int get_max(int a[], int n) {
    int res = a[0];
    for (int i = 1; i < n; i ++) {
        if (res < a[i]) {
            res = a[i];
        }
    }
    return a[i];
}

上述代码就会因为收到一个空指针参数而出现错误。对于本例,我们有两种方法解决此问题。可以在初始化答案时使用可能的最小值:

int get_max(int a[], int n) {
    int res = (1 << 31);
    for (int i = 1; i < n; i ++) {
        if (res < a[i]) {
            res = a[i];
        }
    }
    return a[i];
}

或是特判边界条件:

int get_max(int a[], int n) {
    if (n == 0) {
        return (1 << 31);
    }
    int res = a[0];
    for (int i = 1; i < n; i ++) {
        if (res < a[i]) {
            res = a[i];
        }
    }
    return a[i];
}

建议为单行 if/for/while 加上大括号

我们知道,如果 if/for/while 不加大括号,就表示只对一行生效,例如:

if (x == 1)
    printf("x = 1\n");
for (i = 0; i < n; i ++)
    if (i != 5)
        printf("i = %d\n", i);
while (x++ < 10)
    f(x);

这样当然没有错误,但是无论是初学者还是有经验者,我都建议你在任何情况下加上大括号。理由很简单,当你想给这种 if/for/while 内部的内容加上一行时,一旦疏忽就会出错,例如:

for (i = 0; i < n; i ++)
    if (i != 5)
        printf("i = %d\n", i);
        test(x, i);

你以为你的代码是怎样的?他其实是这样的:

for (i = 0; i < n; i ++)
    if (i != 5)
        printf("i = %d\n", i);
test(x, i);

当然,对于有经验者而言,这种错误并不难排查,但我们没有必要非得给自己挖坑,不是吗?加上大括号,一方面我们可以很清晰地看出程序的逻辑结构,另一方面我们可以很轻松地修改 if/for/while 内部的内容而不用担心忘记加上大括号。

if (x == 1) {
    printf("x = 1\n");
}
for (i = 0; i < n; i ++) {
    if (i != 5) {
        printf("i = %d\n", i);
    }
}
while (x++ < 10) {
    f(x);
}

写循环时关注下标越界

养成习惯:当你在循环中使用间接的下标时,总是关注下标越界问题。例如你在一个 i[0,n1]i \in [0, n-1] 的循环中会访问数组元素,而当你使用 a[i+1]a[i + 1]a[i1]a[i - 1] 时,就应当认识到循环条件需要更改。如果能够养成关注下标越界的习惯,你就不会写出类似下面这样的代码:

for (i = 0; i < n; i ++) {
    if (a[i] > a[i + 1]) {
        t = a[i];
        a[i] = a[i + 1];
        a[i + 1] = t;
    }
}

当然,这种错误本身也不难排查,只不过,如果养成习惯,只要使用间接的下标,就去考虑越界:

for (i = 0; i < n; i ++) {
    if (a[i] > // 当你即将写下 a[i + 1] 时
}

那么你就能在多数时候提前发现问题,很大地减少犯下此类错误的概率:

for (i = 0; i + 1 < n; i ++) { // 及时对循环条件进行应有的改变
    if (a[i] > a[i + 1]) {
}

多进行阶段性测试

养成习惯:在写代码量比较大或很大的程序时,每写完一个局部的代码后,都要考虑进行阶段性测试,保证这一部分的正确性。这样,当出现 BUG 时,我们可以认为错误大概率发生在新写的代码中,而错误出现在之前的代码中的概率很低。即便是出现在之前的部分中,你也能根据之前的阶段测试的结果,排除掉一些可能性。这可以提高我们的编程效率。

多考虑是否应该使用函数

养成习惯:在写代码时,总是考虑这段代码是不是一个独立的子功能,是否可以包装成函数。


总结

好的代码风格和编程习惯对你自己而言可能并非是“必要的”,对于一些特别聪明的或是编程有天赋的人来说,即便是把代码写得乱七八糟,他也能很快地理清代码的逻辑结构,或是很快地完成 DEBUG 找到错误。但是即便你属于这一类人,好的代码风格可以让你在将来扩展自己代码的时候节省很多精力,也可以让别人能够很好地理解你的代码、与你合作完成任务,好的编程习惯仍然能降低你犯下低级错误的风险。因此,培养好的代码风格和编程习惯是百利而无一害的事情。

或许现在你觉得这些会增大你的学习负担,但是很快你就会感受到,好的代码风格和编程习惯对你编程效率的提升,以及你一定会在未来的某个时刻意识到它们有多重要。


文章更新记录

更新时间更新内容
2020.7.15追加新的代码风格注意事项