编写可读代码的艺术

92 阅读23分钟

本文大部分素材来自于《编写可读代码的艺术》一书

这是一篇关注编码细节的文章,或许你会认为本文所讲皆为小道,诸如方法命名、变量定 义、语句组织、任务分解等内容,俱是细枝末节,微不足道。然而,对于一个整体的软件系统而言,既需要宏观的架构决策、设计与指导原则,也必须重视微观的代码细节。正如作文,提纲主旨是文章的根与枝,但一词一句,也需精雕细琢,才能立起文章的精气神。 所谓“细节决定成败”,在软件历史中,有许多影响深远的重大失败,其根由往往是编码细节出现了疏漏。

如何让代码变得“更好”?

大多数程序员依靠直觉和灵感来决定如何编程,我们都知道这样的代码:

for (Node* node = list->head; node != NULL; node = node->next) {
    Print(node->data);
    // ...
}

比下面的代码好:

Node* node = list->head;
if(node== NULL) return;

while (node->next != NULL) {
    Print(node->data);
    node = node->next;
}

if(node != NULL) Print(node->data);

但很多时候这个选择会很艰难,例如这段代码:

return exponent >= 0 ? mantissa * (1 << exponent): mantisssa/(1 <<-exponent);

它比下面这段要好还是差?

if (exponent >= 0) {
    return mantissa * (1 << exponent);
} else {
    return mantissa / (1 << -exponent);
}

第一个版本更紧凑,第二个版本更直白。哪个标准更重要呢?一般情况下,在写代码时你如何来选择?先卖个关子,相信读完本文后你会有自己的判断。

代码应当易于理解

关键思想:代码的写法应当使别人理解它所需的时间最小化

这是什么意思?其实很直接,如果你叫一个普通的同事过来,测算一下他通读你的代码并理解它所需的时间,这个“理解代码时间”就是你要最小化的理论度量。

并且当我们说“理解”时,我们对这个词有个很高的标准。如果有人真的完全理解了你的代码,他就应该能改动它、找出缺陷并且明白它是如何与你代码的其他部分交互的。

可读性有这么多讲究吗,不就是给函数取一个合适的名字?

取一个合适的名字只是增强可读性的手段之一,还有很多其他经验值得学习。甚至有些经验你可能之前意识到过,只是没形成理论,没得到共鸣。另外,相信大家应该有同感,取名一个合适的名字并非易事,不能期望谁都能善用这项技能,也不能期待每个人对名字的理解一致。

我是唯一使用这段代码的人,谁会关心它的可读性?

就算你从事只有一个人的项目,这个目标也是值得的。那个“其他人”可能就是6个月后的自己,那时你自己的代码看上去已经很陌生了。而且你永远也不会知道,说不定别人会加人你的项目,或者你“丢弃的代码”会在其他项目里重用。

代码是越少越好吗?

不一定,先来看个例子:

// 但少的代码并不总是更好!很多时候,像下面这样的一行表达式
assert((!(bucket = FindBucket(key))) || !bucket->IsOccupiced());

// 理解起来要比两行代码花更多时间:
bucket = FindBucket(key);
if(bucket != NULL) assert(!bucket->IsOccupied());

尽管减少代码行数是一个好目标,但把理解代码所需的时间最小化是一个更好的目标。另外,可读性和少代码很多时候并不冲突,后面会有一些案例,可以从案例中再多体会下。

可读性与其他目标有冲突,如何抉择?

像是使代码更高性能、更好架构、更容易测试等,这些会不会在有些时候与使代码容易理解这个目标冲突吗?

大多数情况下,可读性不会与这些目标互相影响,就算是在需要高度优化代码的领域,还是有办法能让代码同时具备可读性,并且让你的代码容易理解往往会把它引向好的架构且容易测试。

至于性能,这里有一些经验之谈,根据经验,可读性和性能偶尔存在有一定矛盾。建议性能不是瓶颈时,可读性优先。通常来讲,框架代码会比较追求性能,一般的业务代码可以考虑可读性优先。

Talk is cheap, let's show the code

【命名】把信息装进名字里

找到更有表现力的词,避免空泛的名字(tmp、retval等)

单词更多选择
senddeliver、dispatch、announce、distribute、route
findsearch、extract、locate、recover
startlaunch、create、begin、open
makecreate、set up、build、generate、compose、add、new

给变量名带上重要的细节

函数参数带单位的参数
Start(int delay)delay → delay_secs
CreateCache(int size)size → size_mb
ThrottleDownload(float limit)limit → max_kbps
Rotate(float angle)angle → degrees_cw
情形变量名更好的名字
一个"纯文本"格式的密码,需要加密后才能进一步使用passwordplaintext_password
一条用户提供的注释,需要转义之后才能用于显示commentunescaped_comment
已转化为UTF-8格式的html字节htmlhtml_utf8
以"url方式编码"的输入数据datadata_urlenc

为作用域大的名字采用更长的名字,在小的作用域里可以使用短的名字

if (debug){
    map<string,int> m;
    LookUpNamesNumbers(&m);
    Print(m);
}

尽管m这个名字并没有包含很多信息,但这不是个问题,因为读者已经有了需要理解这段代码的所有信息。

首字母缩略词和缩写

把一个类命名为 BEManager 而不是 BackEndManager,这种名字会让人费解,冒这个风险是否值得?
经验原则是:团队的新成员是否能理解这个名字的含义?如果能就没问题,反之则需要换个更易懂的名字。

丢掉没用的词

有时名字中的某些单词可以拿掉而不会损失任何信息。例如,ConvertToString() 就不如 Tostring() 这个更短的名字,而且没有丢失任何有用的的信息。同样,不用 DoServeLoop(),ServeLoop() 也一样清楚。

利用名字格式来传递信息

类 CamelCase、变量 lower_separated、宏 MACRO_NAME、常量 kConstantName、枚举 ENUM_NAME、类私有变量 _privite)

【格式】让排版充满美
通过排版让注释看起来整齐

bad:

public class PerformanceTester {
    public static final TcpConnectionSimulator wifi = new TcpConnectionSimulator(
        500, /* Kbps */
        80, /* millisecs latency */
        200, /* jitter */
        1 /* packet loss % */
    );
    public static final TcpConnectionSimulator t3_fiber = new TcpConnectionSimulator(
        45000, /* Kbps */
        10, /* millisecs latency */
        0, /* jitter */
        0 /* packet loss % */
    );
    public static final TcpconnectionSimulator cell = new TcpconneectionSimulator(
        100, /* Kbps */
        400, /* millisecs latency */
        250, /* jitter */
        5 /* packet loss % */
    );
}

good:

public class PerformanceTester {
    public static final TcpConnectionSimulator wifi =
        new TcpConnectionSimulator(
            500,   /* Kbps */
            80,    /* millisecs latency */
            200,   /* jitter */
            1      /* packet loss % */);
    public static final TcpConnectionSimulator t3_fiber =
        new TcpConnectionSimulator(
            45000, /* Kbps */
            10,    /* millisecs latency */
            0,     /* jitter */
            0      /* packet loss % */);
    public static final TcpconnectionSimulator cell =
        new TcpconneectionSimulator(
            100,   /* Kbps */
            400,   /* millisecs latency */
            250,   /* jitter */
            5      /* packet loss % */);
}

better:

public class PerformanceTester {
    // TcpConnectionSimulator(throughput, latency, jitter, packet_loss)
    //                        [Kbps]      [ms]     [ms]    [percent]
    public static final TcpConnectionSimulator wifi =
        new TcpConnectionSimulator(500, 80, 200, 1);
    public static final TcpConnectionSimulator t3_fiber =
        new TcpConnectionSimulator(45000, 10,0,0,0);
    public static final TcpConnectionSimulator cell =
        new TcpConnectionSimulator(100, 400, 250, 5);
}

合理的封装

bad:

DatabaseConnection database_connection;
string error;
assert(ExpandFullName(database_connection, "Doug Adams", &error)
    == "Mr. Douglas Adams");
assert(error == "");
assert(ExpandFullName(database_connection," Jake Brown",&error)
    == "Mr. Jacob Brown III");
assert(error == "");
assert(ExpandFullName(database_connection, "No Such Guy", Berroor) == "");
assert(error == "no match found");
assert(ExpandFullName(database_connection, "John", aerror) == "");
assert(error == "more than one result");

good:

CheckFullName("Doug Adams", "Mr. Douglas Adams", "");
CheckFullName(" Jake Brown", "Mr. Jacob Brown III", "");
CheckFullName("No Such Guy", "", "no match found");
CheckFullName("John", "", "more than one result");

现在,很明显这里有4个测试,每个使用了不同的参数。尽管所有的"脏活" 都放在CheckFullName()中,但是这个函数也没那么差:

void CheckFullName(string partial_name,
                   string expected_full_name,
                   string expected_error){
    // database_connection is now a class member
    string error;
    string full_name = ExpandFullName(database_connection, partial_name, aerror);
    assert(error == expected_error);
    assert(full_name == expected_full_name);
}

another choice:

有时你可以借用"列对齐"的方法来让代码易读。例如,在前一部分中,你可以用空白CheckFullName()的参数排成:

CheckFullName("Doug Adams" , "Mr. Douglas Adams"  , "");
CheckFullName(" Jake Brown", "Mr. Jacob Brown III", "");
CheckFullName("No Such Guy", ""                   , "no match found");
CheckFullName("John".      , ""                   , "more than one result");

在这段代码中,很容易区分出CheckFullName()的第二个和第三个参数。下面是一个简单的例子,它有一大组变量定义,通过列对齐的方式很容易发现phone的赋值“equest”写错了:

# Extract POST parameters to local variables
details  = request.POST.get('details')
location = request.POST.get('location')
phone    = equest.POST.get(phone')
email    = request.POST.get('email')
url      = request.POST.get('url')

把申明按块组织代码

bad:

class FrontendServer {
    public:
        FrontendServer();
        void ViewProfile(HttpRequest* request);
        void OpenDatabase(string location, string user);
        void SaveProfile(HttpRequest* request);
        string ExtractQueryParam(HttpRequest* request, string param);
        void ReplyOK(HttpRequest* request, string html);
        void FindFriends(HttpRequest* request);
        void ReplyNotFound(HttpRequest* request, string error);
        void CloseDatabase(string location);
        ~FrontendServer();
}

good:

class FrontendServer {
    public:
        Frontendserver();
        ~Frontendserver();
        
        // Handlers
        void ViewProfile(HttpRequest* request);
        void SaveProfile(HttpRequest* request);
        void FindFriends(HttpRequest* request);
        
        // Request/Reply Utilities
        string ExtractQueryParam(HttpRequest* request, string param);
        void ReplyOK(HttpRequest* request, string html);
        void ReplyNotFound(HttpRequest* request, string error);
        
        // Database Helpers
        void OpenDatabase(string location, string user);
}

把代码分成段落

bad:

# Import the user's email contacts, and match them to users in our system.
# Thendisplay a list of those users that he/she isn'talready friends with.
def suggest_new_friends(user, email_password):
    friends = user.friends()
    friend_emails = set(f.email for f in friends)
    contacts = import_contacts(user.email, email_password)
    contact_emails = set(c.email for c in contacts)
    non_friend_emails = contact_emails - friend_emails
    suggested_friends = User.objects.select(email_in=non_friend_emaiis)
    display['user'] = user
    display['friends'] = friends
    display['suggested_friends'] = suggested_friends
    return render("suggested_friends.html", display)

good:

def suggest_new_friends(user, email_password):
    # Get the user's friends' email addresses.
    friends = user.friends()
    friend_emails = set(f.email for f in friends)
    
    # Import all email addresses from this user's email account
    contacts = import_contacts(user.email, email_password)
    contact_emails = set(c.email for c in contacts)
    
    # Find matching users that they aren't already friends with.
    non_friend_emails = contact_emails - friend_emails
    suggested_friends = User.objects.select(email_in=non_friend_emails)
    
    # Display these lists on the page.
    display['user'] = user

个人风格与一致性(一致的风格比“正确”的风格更重要)

有相当一部分审美选择可以归结为个人风格。例如,类定义的大括号该放在哪里:

class Logger {
    ...
};

// 还是: 

class Logger
{
    ...
}

选择一种风格而非另一种,不会真的影响到代码的可读性。但如果把两种风格混在一 起,就会对可读性有影响了。

【注释】该写什么样的注释
关键思想:注释的目的是尽量帮助读者了解得跟作者一样多

什么不需要注释

// The class definition for Account
class Account {
    public:
    // Constructor
    Account();
    
    // Set the profit member to a new value
    void SetProfit(double profit);
    
    // Return the profit from this Account
    double GetProfit();
};

不要为了注释而注释

// Find the Node in the given subtree, with the given name, usingthe given depth.
Node* FindNodeInSubtree(Node* subtree, string name, int depth);

这种情况属于"没有价值的注释"一类,函数的声明与其注释实际上是一样的。对于这条注释要么删除它,要么改进它。

如果你想要在这里写条注释,它最好也能给出更多重要的细节:

// Find a Node with the given 'name' or return NULL.
// If depth <= 0, only'subtree' is inspected.
// If depth == N, only 'subtree' and N levels bellow are inspected.
Node* FindNodeInSubtree(Node* subtree, string name, intdepth);

不要给不好的名字加注释,应该把名字改好

示例一:

// Enforce limits on the Reply as stated in the Request,
// such as the number of items returned, or total byte size, etc.
void CleanReply(Request request, Reply reply);

这里大部分的注释只是在解释“clean”是什么意思,更好的做法是把“enforce limits”这个词组加到函数名里:

// Make sure 'reply'meets the count/byte/etc.limits from the'request'
void EnforcelimitsFromRequest(Request request, Reply replyy);

示例二:

// Releases the handle for this key. This doesn't modify the actual registry.
void DeleteRegistry(Registrykey* key);

DeleteRegistery()这个名字听起来像是一个很危险的函数(它会删除注册表?!)注释里的"它不会改动真正的注册表"是想澄清困惑。

我们可以用一个更加自我说明的名字,就像:

void ReleaseRegistryHandle(RegistryKey* key);

加入“导演评论”

电影中常有"导演评论"部分,电影制作者在其中给出自己的见解并且通过讲故事来帮助你理解这部电影是如何制作的。同样,你应该在代码中也加加入注释来记录你对代码有价值的见解。

下面是一个例子:

// 出乎意料的是,对于这些数据用二叉树比用哈希表快40%
// 哈希运算的代价比左/右比较大得多

这段注释教会读者一些事情,并且防止他们为无谓的优化而浪费时间。

下面是另一个例子:

// 作为整体可能会丢掉几个词。这没有问题。要100%解决太难了

如果没有这段注释,读者可能会以为这是个bug然后浪费时间尝试找到能让它失败的测试用例,或者尝试改正这个bug。

注释也可以用来解释为什么代码写得不那么整洁:

// 这个类正在变得越来越乱
// 也许我们应该建立一个'ResourceNode'子类来帮助整理

这段注释承认代码很乱,但同时也鼓励下一个人改正它(还经合出了具体的建议)。如果没有这段注释,很多读者可能会被这段乱代码吓到而不敢碰它。

为代码中的瑕疵写注释

// TODO: 采用更快算法

或者当代码没有完成时:

// TODO(dustin): 处理除JPEG以外的图像格式

有几种标记在程序员中很流行:

标记通常的意义
TODO:我还没有处理的事情
FIXME:已知的无法运行的代码
HACK:对一个问题不得不采用的比较粗糙的解决方案
XXX:危险!这里有重要的问题

给常量加注释

当定义常量时,通常在常量背后都有一个关于它是什么或者为什么它是这个值的“故事”。例如,你可能会在代码中看到如下常量:

NUM_THREADS = 8

这一行看上去可能不需要注释,但很可能选择用这个值的程序员知道得比这个要多:

NUM_THREADS = 8 # as long as it's >= 2 * num_processors, that's good enough.

现在,读代码的人就有了调整这个值的指南了(比如,设置成1可能就太低了,设置成 50又太夸张了)。 或者有时常量的值本身并不重要,达到这种效果的注释也会会有用:

// Impose a reasonable limit - no human can read that much anyway
const int MAX_RSS_SUBSCRIPTIONS = 1000;

还有这样的情况,它是一个高度精细调整过的值,可能不应该大幅改动。

// users thought 0.72 gave the best size/quality tradeoff
image_quality = 0.72;

意料之中的提问

struct Recorder {
    vector<float> data;
    ...
    void Clear() {
        vector<float>().swap(data); // Huh? Why not just data.clear()?
    }
};

大多数C++程序员看到这段代码时都会想:“为什么他不直接用data.clear()而是与一个空的向量交换?”实际上只有这样才能强制使向量真正地把内为存归还给内存分配器。这不是一个众所周知的C++细节,起码要加上这样的注释:

// Force vector to relinquish its memory (look up "STL swap trick(")
vector<float>().swap(data);

公布可能的陷阱

void SendEmail(string to, string subject, string body);

这个函数的实现包括连接到外部邮件服务,这可能会花整整一沙,或者更久。可能有人在写Web应用时在不知情的情况下错误地在处理HTTP请求时调用这个函数(这么做可能会导致他们的Web应用在邮件服务宕机时“挂起”)。为了避免这种灾难,你应当为这个“实现细节”加上注释:

// 调用外部服务来发送邮件(1分钟之后超时)
void SendEmail(string to, string subject, string body);

下面有另一个例子:假设你有一个函数 FixBrokenHtml() 用来尝试重写损坏的 HTML,通过插入结束标记这样的方法:

def FixBrokenHtml(html): ...

这个函数运行得很好,但要警惕当有深嵌套而且不匹配的标记时它的运行时间会暴增。对于很差的HTML输入,该函数可能要运行几分钟。与其让用户自己慢慢发现这一点,不如提前声明:

// 影响运行时间的因素为:number_tags * average_tag_depth,所以小心严重嵌的输入
def FixBrokenHtml(html): ...

全局观注释

思考下面的场景:有新人刚刚加入你的团队,她坐在你旁边,而你需要让她熟悉代码库。在你带领她浏览代码库时,你可能会指着某些文件或者类说这样的话:

    • 这段代码把我们的业务逻辑与数据库粘在一起,任何应用层代码都不该直接使它
    • 这个类看上去很复杂,但它实际上只是个巧妙的缓存,它对系统中的其他部分一无所知

在一分钟的随意对话之后,你的新团队成员就知道得比她自己读源代码更多了。这正是那种应该包含在高级别注释中的信息,下面是一个文件级别注释的简单例子:

// 这个文件包含一些辅助函数,为我们的文件系统提供了更便利的接口
// 它处理了文件权限及其他基本的细节。

不要对于写庞大的正式文档这种想法不知所措,几句精心选择的话比什么都没有强。

克服“作者心理阻滞”

很多程序员不喜欢写注释,因为要写出好的注释感觉好像要花很多工夫。当作者有了这 种“作者心理阻滞”,最好的办法就是现在就开始写。因此下次当你对写注释犹豫不决时,就直接把你心里想的写下来就好了,虽然这种注释可能是不成熟的。例如,假设你正在写一个函数,然后心想:“哦,天啊,如果一旦这东西在列表中有重复的话会变得很难处理的。”那么就直接把它写下来:

// 哦,天啊,如果一且这东西在列表中有重复的话会变得很难处理的

看到了,这难吗?它作为注释来讲实际上没那么差——起码比没有强。可能措辞有点含糊。要改正这一点,可以把每个句子改得更专业一些:

    • “哦,天啊”,实际上,你的意思是“小心:这个地方需要注意”
    • “这东西”,实际上,你的意思是“处理输人的这段代码"
    • “会变得很难处理”,实际上,你的意思是”会变得难以实现"

新的注释可以是:

// 小心:这段代码不会处理列表中的重复(因为这很难做到)

请注意我们把写注释这件事拆成了几个简单的步骤:

  1. 不管你心里想什么,先把它写下来
  2. 读一下这段注释,看看有没有什么地方可以改进
  3. 不断改进

写出言简意赅的注释

// The int is the CategoryType.
// The first float in the inner pair is the 'score',
// the second is the 'weight'.
typedef hash_map<int,pair<float,float>> ScoreMap;

可是为什么解释这个例子要用三行呢,用一行不就可以了吗?

// CategoryType -> (score, weight)
typedef hash_map<int,pair<float,float>> ScoreMap;

避免使用不确定的代词

// Insert the data into the cache, but check if it's too big first.

在这段注释中,“it”可能指数据也可能是指缓存。可能在读完剩下的代码后你会找到答案。但如果你必须这么做,又要注释干什么呢?最安全的方式是,如果在有可能会造成困惑的地方把“填写”代词。在前一个例子中,假设“it”是指“data”,那么:

// Insert the data into the cache, but check if the data is too big first.

精确的描述函数的行为

// Return the number of lines in this file.
int CountLines(string filename){
    // ...
}

上面的注释并不是很精确,因为有很多定义“行”的方式,下面列出几个特别的情况:

  • "" (空文件) -- 0或1行?
  • "hello" -- 0或1行?
  • "hello\n" -- 1或2行?
  • "hello\nworld" -- 1或2行?
  • "hello\n\r world\r" -- 2、3或4行?

最简单的实现方法是统计换行符 ('\n') 的个数,下面的注释对于这种实现方法更好一些:

// Count how many newline bytes ('\n') are in the file.
int CountLines(string filename){
    // ...
}

这条注释并没有比第一个版本长很多,但包含更多信息。它告诉读者如果没有换行符,这个函数会返回0,它还告诉读者回车符 ('\r') 会被忽略。

用输入/输出例子来说明特别的情况

// Remove the suffix/prefix of 'chars' from the input 'src'.
String Strip(String src, String chars){
    // ...
}

这条注释不是很精确,因为它不能回答下列问题:

  • chars 是整个要移除的子串,还是一组无序的字母?
  • 如果在 src 的结尾有多个 chars 会怎样?

然而一个精心挑选的例子就可以回答这些问题:

// Example: Strip("abba/a/ba", "ab") returns "/a/"
String Strip(String src, String chars){
    // ...
}

申明代码意图

void DisplayProducts(list<Product> products) {
    products.sort(CompareProductByPrice);
    
    // Iterate through the list in reverse order
    for (list<Product>::reverse_iterator it = products.rbegin(); it != products.rend(); ++it) {
        DisplayPrice(it->price);
        // ...
    }
}

这里的注释只是描述了它下面的那行代码。相反,更好的注释可以是这样的:

// Display each price, from highest to lowest
for (list<Product>::reverse_iterator it = products.rbeegin();...)

可以使用嵌入式注释

// Call the function with commented parameters
Connect (/* timeout_ms = */ 10, /* use_encryption = */ false);

【逻辑】条件和循环

条件语句中参数的顺序

下面的两段代码哪个更易读?
if (length >= 10)
还是
if (10 <= length)
对大多数程序员来讲,第一段更易读。那么,下面的两段呢?
while (bytes_received < bytes_expected)
还是
while (bytes_expected > bytes_received)
仍然是第一段更易读,可为什么会这样?通用的规则是什么?你怎么才能决定是写成 a < b 好一些,还是写成 b > a 好一些?下面的这条指导原则很有帮助:

比较的左侧比较的右侧
“被问询的”表达式,它的值更倾向于不断变化用来做比较的表达式,它的值更倾向于常量

if/else语句块的顺序

  • 首先处理正逻辑而不是负逻辑的情况。例如用 if(debug) 而不是 if(!debug)
  • 先处理掉简单的情况。这种方式可能还会使得 if 和 else 在屏幕之内都可见,这很好
  • 先处理有趣的或者是可疑的情况

三目运算符

下面是一个三目运算符易读而又紧凑的应用:

time_str += (hour >= 12) ? "pm" : "am"

要避免三目运算符,你可能要这样写:

if (hour >= 12) {
    time_str += "pm";
} else {
    time_str += "am";
}

这有点冗长了,在这种情况下使用条件表达式似乎是合理的。
然而,下面这种表达式就会变得很难读:

return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);

在这里,三目远算已经不只是从两个简单的值中做选择。写出这种代码的动机往往是“把所有代码都挤进一行里”。

避免使用do/while循环,表达式至少会执行一次,不符合理解习惯

通过提前返回减少嵌套(卫语句)

bad:

if(user_result == SUCCESS) {
    if (permission_result != SUCCESS) {
        reply.WriteErrors("error reading permissions");
        reply.Done();
        return;
    }
    reply.WriteErrors("");
} else {
    reply.WriteErrors(user_result);
}
reply.Done();

good:

if(user_result != SUCCESS) {
    reply.WriteErrors(user_result);
    reply.Done();
    return;
}

if (permission_result != SUCCESS) {
    reply.WriteErrors(permission_result);
    reply.Done();
    return;
}

reply.WriteErrors("");
reply.Done();

减少循环内嵌套

提早返回这个技术并不总是合适的。例如,下面代码在循环中有嵌套:

for (int i = 0; i < results.size(); i++) {
    if (results[i] != NULL) {
        non_null_count++;
        if (results[i]->name != "") {
            cout << "Considering candidate..." << endl;
            // ...
        }
    }
}

在循环中,与提早返回类似的技术是continue:

for (int i = 0; i < results.size(); i++) {
    if (results[i] == NULL) continue;
    non_null_count++;
    
    if (results[i]->name == "") continue;
    cout << "Considering candidate..." << endl;
    
    // ...
}

拆分超长表达式,用做解释的变量

示例一:

if (line.split(':')[0].strip() == "root") {
    // do something
}

下面是和上面同样的代码,但是现在有了一个解释变量。

let username = line.split(':')[0].strip();
if (username === 'root') {
    // do something
}

示例二:
bad:

if(request.user.id == document.owner_id) {
    // user can edit this document...
}

// ...

if(request.user.id != document.owner_id) {
    // document is read-only...
}

good:

final boolean user_owns_document = (request.user.id == documment.owner_id);
if (user_owns_document){
    // user can edit this document...
)

// ...

if(!user_owns_document){
    // document is read-only...
}

使用德摩根定理

  1. not (a or b or c) <=> (not a) and (not b) and (not c)
  2. not (a and b and c) <=> (not a) or (not b) or (not c)

如果你记不住这两条定理,一个简单的小结是“分别取反,转换与/或”(反向操作是 “提出取反因子”)。
有时,你可以使用这些法则来让布尔表达式更具可读性。例如,如果你的代码是这样的:
if (!(file_exists && !is_protected)) Error("Sorry, could not read file.")
那么可以把它改写成:
if (!file_exists || is_protected) Error("Sorry, could not rread file.")

拆分巨大语句

bad:

var update_highlight = function (message_num) {
    if($("#vote_value" + message_num).html() === "Up") {
        $("#thumbs_up" + message_num).addClass("highlighted");
        $("#thumbs_down" + message_num).removeClass("highlighted");
    } else if ($("#vote_value" + message_num).html() === "Down") {
        $("#thumbs_up" + message_num).removeClass("highlighted");
        $("#thumbs_down" + message_num).addClass("highlighted");
    } else {
        $("#thumbs_up" + message_num).removeClass("highighted");
        $("#thumbs_down" + message_num).removeClass("highlighted");
    }
}

good:

var update_highlight = function (message_num){
    var thumbs_up = $("#thumbs_up" + message_num);
    var thumbs_down = $("#thumbs_down" + message_num);
    var vote_value = $("#vote_value" + message_num).html();
    var hi = "highlighted";
    
    if (vote_value === "Up"){
        thumbs_up.addClass(hi);
        thumbs_down.removeClass(hi);
    } else if (vote_value === "Down") {
        thumbs_up.removeClass(hi);
        thuebs_down.addClass(hi);
    } else {
        thumbs_up.removeClass(hi);
        thumbs_down.removeClass(hi);
    }
};

创建 var hi = "highlighted" 严格来讲不是必需的,但鉴于这里有6次重复,有很多好处驱使我们这样做:

  • 它帮助避免录入错误(实际上,你是否注意到在第一个例子中,该字符串在第5种情况中被误写成"highhighted"?)
  • 它进一步缩短了行的宽度,使代码更容易快速阅读。
  • 如果类的名字需要改变,只需要改一个地方即可。

【变量】可读性

去掉没有价值的临时变量

now = datetime.datetime.now()
root_message.last_view_time = now

减少中间结果

bad:

var remove_one = function (array, value_to_remove) {
    var index_to_remove = null;
    for (var i=0; i<array.length; i+=1){
        if (array[i] === value_to_remove) {
            index_to_remove = i;
            break;
        }
        if (index_to_remove !== null){
            array.splice(index_to_remove, 1);
        }
    }
}

good:

var remove_one = function (array, value_to_remove) {
    for (var i=O; i<array.length; i+=1){
        if (array[i] === value_to_remove) {
            array.splice(i, 1);
            return;
        }
    }
}

缩小变量作用域,让你的变量对尽量少的代码行可见

bad:

class LargeClass {
    string str_;
    
    void Method1() {
        str_ = Method2();
    }
    
    void Method2() {
        // Uses str_
    }
    
    // Lots of other methods that don't use str_...
};

good:

class LargeClass {
    void Method1() {
        string str = ...;
        Method2(str);
    }
    
    void Method2(string str){
        // Uses str
    }
    
    // Now other methods can't see str.
};

把定义向下移

bad:

def ViewFilteredReplies(original_id):
    filtered_replies = []
    root_message = Messages.objects.get(original_id)
    all_replies = Messages.objects.select(root_id=original_id)
        root_message.view_count += 1
        root_message.last_view_time = datetime.datetime.now()
        root_message.save()
        
        for reply in all_replies:
            if reply.spam_votes <= MAX_SPAM_VOTES:
                filtered_replies.append(reply)
                
        return filtered_replies

good:

def ViewFilteredReplies(original_id):
    root_message = Messages.objects.get(original_id)
    root_message.view_count += 1
    root_message.last_view_time = datetime.datetime.now()
    
    root_message.save()
    
    all_replies = Messages.objects.select(root_id=original_id)
    filtered_replies = []
    for reply in all_replies:
        if reply.spam_votes <= MAX_SPAM_VOTES:
        filtered_replies.append(reply)
        
    return filtered_replies

【封装】代码抽象

分离不相关的子问题

bad:

下面JavaScript代码的高层次目标是“找到距离给定点最近的位置”(请勿纠结于斜体部 分所用到的高级几何知识):

// Return which element of 'array' is closest to the given latitude/longitude
// Models the Earth as a perfect sphere.
var findClosestLocation = function (lat, lng, array) {
    var closest;
    var closest_dist = Number.MAX_VALUE;
    for (var i = 0; i < array.length; i += 1) {
        // Convert both points to radians.
        var lat_rad = radians(lat);
        var lng_rad = radians(lng);
        var lat2_rad = radians(array[i].latitude);
        var lng2_rad = radians(array[i].longitude);
        
        // Use the "Spherical Law of Cosines" formula.
        var dist = Math.acos(Math.sin(lat_rad) * Math.sin(lat2_rad) +
                             Math.cos(lat_rad) * Math.cos(lat2_rad) *
                             Math.cos(lng2_rad - lng_rad));
        if (dist < closest_dist) {
            closest = array[i];
            closest_dist = dist;
        }
    }
    
    return closest;
}

good:

循环中的大部分代码都旨在解决一个不相关的子问题:“计算两个经纬坐标点之间的球 面距离”。因为这些代码太多了,把它们抽取到一个独立的sphericaal_distance()函数是合理的:

var spherical_distance = function (lat1, lng1, lat2, lng2) {
    var lat1_rad = radians(lat1);
    var lng1_rad = radians(lng1);
    var lat2_rad = radians(lat2);
    var lng2_rad = radians(lng2);
    
    // Use the "Spherical Law of Cosines" formula.
    return Math.acos(Math.sin(lati_rad) * Math.sin(lat2_rad) +
                     Math.cos(lat1_rad) * Math.cos(lat2_rad) *
                     Math.cos(lng2_rad - lng1_rad));
 };

现在,剩下的代码变成了:

var findClosestLocation = function (lat, lng, array) {
    var closest;
    var closest_dist = Number.MAX_VALUE;
    for (var i = 0; i < array.length; i += 1){
        var dist = spherical_distance(lat, Ing, array[i].latitude, array[i].longittude);
        if (dist < closest_dist) {
            closest = array[i];
            closest_dist = dist;
        }
    }
    return closest;
};

这段代码的可读性好得多,因为读者可以关注于高层次目标,而不必因为复杂的几何公式分心。作为额外的奖励,spherical_distance()很容易单独做测试,并且sphericaldistance() 是那种在以后可以重用的函数。这就是为什么它是一个“不相关关”的子问题,它完全是自包含的,并不知道其他程序是如何使用它的。

清楚的描述逻辑

下面是来自一个网页的一段 PHP 代码,这段代码在一段安全代码的顶部。它检查是否授权用户看到这个页面,如果没有,马上返回一个页面来告认拆用户他没有授权:

bad:

$is_admin = is_admin_request();
if ($document){
    if (!$is_admin && ($document['username'] != $_SESSION['username'])) {
        return not_authorized;
    }
} else {
    if (!$is_admin) {
        return not_authorized;
    }
}
// continue rendering the page ...

这段代码中有相当多的逻辑,像你在本书第二部分所读到的,这种大的逻辑树不容易理解。这些代码中的逻辑可以简化,但是怎么做呢?让我们从人用自然语言描述这个逻辑开始:

    • 授权你有两种方式:
      • 你是管理员
      • 你拥有当前文档(如果有当前文档的话)
    • 否则,无法授权你

good:

if (is_admin_request())
    // authorized
} else if ($document && ($document['usexname'] == $_SESSION['usexname']))
    // authorized
} else {
    return not_authorized
}
// continue rendering the page ...

这个版本有点不寻常,因为它有两个空语句体。但是代码要少一些,并且逻辑也简单,因为没有反义(前一个方案中有三个“not”),起码它更容易理解。

少写代码

本章是关于写越少代码越好的,每行新的代码都需要测试式、写文档和维护。另外,代码 库中的代码越多,它就越“重”,而且在其上开发就越难。
你可以通过以下方法避免编写新代码:

  • 从项目中消除不必要的功能,不要过度设计。
  • 重新考虑需求,解决版本最简单的问题,只要能完成工作就行。
  • 经常性地通读标准库的整个API,保持对它们的熟悉程度。

一个CR的真实案例
被CR的代码:

CR评语:

一个稍微复杂点的例子(可跳过)

我们需要跟踪在过去的一分钟和一个小时里Web服务器传输了多少字节,下面的图示说 明了如何维护这些总和:

下面是用C++写的第一个类接口版本:

class MinuteHourCounter {
    public:
        // Add a count
        void Count(int num_bytes);
        
        // Return the count over this minute
        int MinuteCount();
        
        // Return the count over this hour
        int HourCount();
};

修改名字和注释:

// Track the cumulative counts over the past minute and over the past hour.
// Useful, for example, to track recent bandwidth usage.
class MinuteHourCounter {
    // Add a new data point (count >= 0).
    // For the next minute, MinuteCount() will be larger by +count.
    // For the next hour, HourCount() will be larger by +count
    void Add(int count);
    
    // Return the accumulated count over the past 60 seconds.
    int MinuteCount();
    
    // Return the accumulated count over the past 3600 seconds.
    int HourCount();
};

尝试1:一个幼稚的方案

让我们来进人下一步,解决这个问题。我们会从一个很直接的方案开始:就是保持一个有时间戳的“事件”列表:

class MinuteHourCounter {
    struct Event {
        Event(int count,time_t time) : count(count), time(time) {}
        int count;
        time_t time;
    };
    list<Event> events;
public:
    void Add(int count){
        events.push_back(Event(count, time()))
    };
    // ...
};

然后我们就可以根据需要计算最近事件的个数。

class MinuteHourCounter {
    // ...
    int MinuteCount() {
        int count = 0;
        const time_t now_secs = time();
        for (list<Event>::reverse_iterator i = events.rbegin();
            i != events.rend() && i->time > now_secs - 60; ++i) {
            count += i->count;
        }
        return count;
    }
    
    int HourCount() {
        int count = 0;
        const time_t now_secs = time();
        for (list<Event>::reverse_iterator i = events.rbegin();
            i != events.rend() && i->time > now_secs - 3600; ++i) {
            count += i->count;
        }
        return count;
    }
};

这段代码易于理解吗?
尽管这个方案是"正确"的,可是其中有很多可读性的问题:

  • for循环太大,一口吃不下。大多数读者在读这部分代码时会显著地慢下来(至少他们应该慢下来,如果他们要确定这里的确没有 bug 的话)
  • MinuteCount() 和 HourCount() 几乎一模一样。如果他们可以共享重复代码就可能让这段代码少一些。这个细节非常重要,因为这些重复的代码相对更复杂(让有难度的代码约束在一起更好些)。

一个更易读的版本
MinuteCount() 和 HourCount() 中的代码只有一个常量不一样 (60和3600)。明显的重构方法是引入一个辅助方法来处理这两种情况:

class MinuteHourCounter {
    list<Event> events;
    
    int CountSince(time_t cutoff) {
        int count = 0;
        for (list<Event>::reverse_iterator rit = events.rbegin();
            rit != events.rend(); ++rit) {
            if (rit->time<=cutoff){
                break;
            }
            count += rit->count;
        }
        return count;
    }

public:
    void Add(int count) {
        events.push_back(Event(count, time()));
    }
    int MinuteCount() {
        return CountSince(time() - 60);
    }
    int HourCount() {
        return CountSince(time() - 3600);
    }
};

在这段新代码中有几件事情值得一提:

  • 首先,请注意 Countsince() 的参数是一个绝对的 cutoff,而不是一个相时的 secs_ago(60或3600)。两种方式都可行,但是这样做对 CountSince() 来讲更容易些。
  • 其次,我们把迭代器从 i 改名为 rit。i 这个名字更常用在整型索引上,我们考虑过用 it 这个名字,这是迭代器的一个典型名字。但这一次我们用的是一个反向选代器,并且这一点对于代码的正确性至关重要。通过在名字前面加一个前缀 r,使它在如 rit != events.rend() 这样的语句中看上去对称。
  • 最后,把条件 rit->time <= Cutoff 从 for 循环中抽取出天来,并把它作为一条单独的 if 语句。为什么这么做?因为保持循环的“传统”格式 for (begin; end; advance) 最容易读。读者会马上明白它是“遍历所有的元素”,并且不需要再做更多的思考。

性能问题
尽管我们改进了代码的外观,但这个设计还有两个严重的生能问题:

  1. 它一直不停地在变大。这个类保存着所有它见过的事件 -- 它对内存的使用是没有限制的!最好 MinuteHourCounter 能自动删除超过一个小时以前的事件,因为不再需要它们了。

2. MinuteCount() 和 HourCount() 太慢了。Countsince() 这个方法的时间为 0(n),其中 n 是在其相关的时间窗口为数据点的个 数。想象一下一个高性能服务器每秒调用 Add() 几百次。每次对 HourCount() 的调用都可能要对上百万个数据点计数!最好 MinuteHourCounter 能记住 minute_count 和 hour_ count 变量,并随每次对 Add() 的调用而更新。

尝试2:传送带设计方案
我们需要一个设计来解决前面提到的两个问题:

  1. 删除不再需要的数据
  2. 更新事先算好的 minute_count 总和和 hour_count 变量总和

我们打算这样做:我们会像传送带一样地使用 list。当新数据在一端到达,我们会在总数上增加。当数据太陈旧,它会从另一端“掉落”,并且我们会从总数中减去它。
有几种方法可以实现这个传送带设计。一种方法是维护两个独立的list,一个用于过去一分钟的事件,一个用于过去一小时。当有新事件到达时,在两个列表中都增加一个拷贝。

这种方法很简单,但它效率并不高,因为它为每个事件创建了两个拷贝。
另一种方法是维护两个 list,事件先会进入第一个列表(“最后一分钟里的事件”),然后这个列表会把数据传送给第二个列表(“最后一小时【但不含最后一分钟】里的事件”)。

这种“两阶段”传送带设计看上去更有效,所以让我们按这个方法实现。
实现两阶段传送带设计
让我们从列出类中的成员开始:

class MinuteHourCounter {
    list<Event> minute_events;
    list<Event> hour_events; // only contains elements NOT in minute_events
    int minute_count;
    int hour_count; // counts All events over past hour, including past minute
}

这个传送带设计的要点在于要能随时间的推移“切换”事件,使事件从 minute_events 移到 hour_events,并且 minute_count 和 hour_count 相应地更新。要做到这一点,我们会创建一个叫做 ShiftoldEvents() 的辅助方法。当我们有了这个方法以后后,这个类的剩余部分很容易实现:

void Add(int count) {
    const time_t now_secs = time();
    ShiftOldEvents(now_secs);
    
    // Feed into the minute list (not into the hour list--that will happeen later)
    minute_events.push_back(Event(count, now_secs));
    
    minute_count += count;
    hour_count += count;
}

int MinuteCount() {
    ShiftOldEvents(time());
    return minute_count;
}

int HourCount() {
    ShiftOldEvents(time());
    return hour_count;
}

明显,我们把所有的脏活儿都放到了 ShiftoldEvents() 里:

// Find and delete old events, and decrease hour_count and minute_count accordingly.
void ShiftOldEvents(time_t now_secs) {
    const int minute_ago = now_secs - 60;
    const int hour_ago = now_secs - 3600;
    
    // Move events more than one minute old from 'minute_events' into 'hour_events'
    // (Events older than one hour will be removed in the seconnd loop.)
    while (!minute_events.empty() && minute_events.front().time <= minutte_ago) {
        hour_events.push_back(minute_events.front());
        
        minute_count -= minute_events.front().count;
        minute_events.pop_front();
    }
    
    // Remove events more than one hour old from 'hour_eventts'
    while (!hour_events.empty() && hour_events.front().time <= hour_ago) {
        hour_count -= hour_events.front().count;
        hour_events.pop_front();
    }
}

这样就完成了吗?
我们已经解决了前面提到了对性能的两点担心,并且我们的方案是可行的。对很多应用来讲,这个解决方案就足够好了。但它还是有些缺点的。
首先,这个设计很不灵活。假设我们希望保留过去24小时的计数。这可能需要改动大量的代码。你可能已经注意到了,ShiftoldEvents() 是一个很集中的函数,在分钟与小时数据间做了微妙的互动。
其次,这个类占用的内存很大。假设你有一个高流量的服务,每分钟调用 Add() 函数 100 次。因为我们保留了过去一小时内所有的数据,所以这段代码可能会需要用到大约 5MB 的内存。
一般来讲,Add() 被调用得越多,使用的内存就越多。在一个产品开发环境中,库使用大量不可预测的内存不是一件好事。最好不论 Add() 被调用得多频繁,MinuteHourCounter 能用固定数量的内存。
尝试3:时间桶设计方案
你应该已经注意到,前面的两个实现都有一个小 bug。我们用 time_t 来保存时间戳,它保存的是一个以秒为单位的整数。因为这个近似,所以 MinuteCourt() 实际上返回的是介于 59~60秒钟的结果,根据调用它的时间而不同。
例如,如果一个事件发生在 time = 0.99 秒,这个 time 会近似成 t = 0 秒。如果你在 time = 60.1 秒调用 MinuteCount(),它会返回 t=1,2,3...60 的事件的总和。因此会遗漏第一个事件,尽管它从技术上来讲发生在不到一分钟以前。
平均来讲,MinuteCount() 会返回相当于 59.5 秒的数据。并且 HourCount() 会返回相当于3599.5秒的数据(一个微不足道的误差)。
可以通过使用亚秒粒度来修正这个误差。但是有趣的是,才大多数使用 MinuteHourCounter 的应用程序不需要这种程度的精度。我们会利用这一点来设计一个新的 MinuteHourCounter,它要快得多并且占用的空间更少,它是在精度与物有所值的性能之 间的一个平衡。
这里的关键思想是把一个小时间窗之内的事件装到桶里,然后用一个总和累加这些事件。例如,过去 1 分钟里的事件可以插人 60 个离散的桶里,每个有 1 秒钟宽。过去1 小时里的事件也可以插入60 个离散的桶里,每个 1 分钟宽。

如图一样使用这些桶,方法 MinuteCount() 和 HourCount() 的精度会是 1/60,这是合理的。
如果要更精确,可以使用更多的桶,以使用更多内存为交换。但重要的是这种设计使用 固定的、可预知的内存。