如何写简短易懂可维护的函数

来源:互联网 发布:淘宝全景图体验app 编辑:程序博客网 时间:2024/05/01 20:04
        代码是由很多个函数(在面向对象的编程语言中叫方法)组成的。函数的可读性、可维护性决定着整个项目代码的质量。因此编写具有高可读性和高可维护性的函数是每个程序员的职责。
        虽然绝大多数程序员都理解可读性和可维护性对代码质量的重要意义,可还是经常不自觉地写出冗长复杂的代码。这些代码不要说今后易于维护,就是读懂都很困难。很多复杂的代码都有一个共同特征,就是充满了上百甚至更多行的函数。函数越长,要理解函数的功能就越困难。同时,由于同一个函数里通过变量关联的代码可能相隔甚远,这也给代码维护增加了困难。

注释不一定能提高函数的可读性和可维护性

        为了使代码容易读懂和容易维护,很多程序员都会选择在函数体内添加注释,用来描述一段代码的作用。这样写注释的动机是好的,然而注释并不一定能提高函数的可读性和可维护性,反过来注释还有可能让读代码的人更加疑惑。很多人在修改代码的时候,注意力都放在代码、功能上面,修改代码之后忙着马上编译、运行。只要功能和预期的一致就觉得可以了,至于与代码匹配的注释,往往会忘记同步修改。一旦注释和代码不一致,以后维护该代码的程序员就会非常困惑,不知道应该相信代码还是相信注释。
        我们先看一个例子。下面的C#代码为某数据库服务生成连接字符串:

public string BuildConnectionString(DatabaseService dbService){    string connection = string.Empty;    // Only running and creating services have connection strings    if (dbService.State == State.Running         || dbService.State == State.Creating)    {        connection = string.Format("server={0};uid={1};pwd={{password}};port={2}",                dbService.ServerID, dbService.Name, dbService.Port);    }    return connection;}

        最开始的时候,只有正在创建的或者正在运行的数据库服务都有连接字符串,处于其他状态的服务的连接字符串都是空字符串。因此在实现该函数的时候,该项目组的程序员写了一个if语句来判断服务的状态。由于判断的逻辑稍显复杂,他还贴心地为该if语句加上注释,告诉其他人这个if语句的作用。
        没过多久,需求发生了变更,只有正在运行的服务有连接字符串,正在创建的数据库服务的连接字符串也为空。这个改动很容易实现。于是很快代码就被改成:

public string BuildConnectionString(DatabaseService dbService){    string connection = string.Empty;    // Only running and creating services have connection strings    if (dbService.State == State.Running)    {        connection = string.Format("server={0};uid={1};pwd={{password}};port={2}",                dbService.ServerID, dbService.Name, dbService.Port);    }    return connection;}

        重新编译、运行程序,一切都如预期的一致。该程序员感觉很好,因为任务完成可以递交代码了。
        三年之后,原先写这段代码的程序员已经离职。这些代码也由另一个新程序员接手。由于某些原因,这位新程序员需要对上面的函数做修改。当他仔细阅读了函数里的代码和注释之后,他困惑了。代码表明只有正在运行的数据库服务才有连接字符串,可注释说正在运行和正在创建的数据库服务都有连接字符串。该相信谁呢?于是他不得不做额外的工作,比如去找熟悉这段代码历史的人,或者去查找代码递交历史,搞清楚每一次改动的来龙去脉。不管用什么方法,都需要花费很多时间。这就是当代码和注释不一致所带来的代价。
        有没有什么办法避免这个问题呢?解决问题的办法是不用注释,让代码本身来解释代码的功能。上述代码中的注释是为了解释if语句的作用,那么我们可以把这个if语句抽取出来放到一个单独的函数,然后给函数一个有意义的命名。例如,上面的函数可以修改为:

public string BuildConnectionString(DatabaseService dbService){    string connection = string.Empty;    if (HaveConnectionString(dbService))    {        connection = string.Format("server={0};uid={1};pwd={{password}};port={2}",                dbService.ServerID, dbService.Name, dbService.Port);    }    return connection;}private bool HaveConnectionString(DatabaseService dbService){    if (dbService.State == State.Running        || dbService.State == State.Creating)    {        return true;    }    return false;}

        在上述改动中,我们把if判断语句抽取出来放到了一个单独的子函数中,并给这个子函数命名为HaveConnectionString。其他人从函数名可以看出该函数的功能是判断一个数据库服务是否有连接字符串。用一个有意义的函数名来代替注释,起到注解的作用,是行之有效的提高代码可读性的方法。
        今后当判断的逻辑有变更的时候,我们只需要修改子函数,其他的地方都不要改动。当正在创建的数据库服务也没有连接字符串的时候,我们可以作如下改动:

private bool HaveConnectionString(DatabaseService dbService){    if (dbService.State == State.Running)    {        return true;    }    return false;}

        这个改动之后,我们不用担心会导致任何不一致的情形。其他人也一样能够一眼就理解代码的意图。


把长函数分隔成若干个短函数

        很多函数长,是因为函数的功能复杂,完成该函数的功能需要多个步骤,每个步骤又需要多条语句。有些程序员会根据步骤把函数分隔成若干个代码段,并为每个步骤对应的代码段加上注释。这样的函数看起来像这样:

public void Work(){    // step 1    ...    ...    ...    // step 2    ...    ...    ...    // step 3    ...    ...    ...}

        一个简单的重构办法是为每一个步骤定义一个子函数,然后按照步骤的执行顺序调用这些子函数。上述代码可以修改为:

public void Work(){    DoStep1();    DoStep2();    DoStep3();}private void DoStep1(){    ...}private void DoStep2(){    ...}private void DoStep3(){    ...}

        这样即使没有没有注释,其他人读到这段代码,一眼也能看出来完成函数Work的功能,需要三个步骤。
        有人可能会问,这个方法不过是把一个200行的函数分隔成10个有20行代码的子函数,又有什么意义?一个好处是短函数更容易读懂。当我们在读代码的时候,很难一眼读懂一个有200行的函数。函数越长,我们越难理解它的逻辑。但当我们把长函数分割成若干个短函数时之后,情况就发生了变化。如果每个子函数都有意义明确的名字,我们不需要去细读子函数的代码,仅仅通过函数名就能知道这段代码的作用。如果真的有必要详细了解子函数的功能,再细读子函数的代码也不迟。例如我们在读上面代码的Work函数时,一眼就知道这个函数分三步执行。如果需要详细哪个步骤,再细读对应子函数的代码。
        同时把长函数分隔成多个简短的子函数,还有益于提高代码的可维护性。在一个包行200行的代码里,很有可能一个变量的两处使用的地点隔了100行。当我们在维护的时候改动了一个地方,未必能注意到对100行之外的另外一个地方也进行相应的改动。这个问题是很多软件越修改缺陷越多的主要原因。如果把长函数分隔成多个简短的子函数,那么变量的作用域显著减少,相应地改动代码的难度也显著降低。当然子函数之间可能会有依赖关系,这些依赖关系可以通过返回值和参数来表示。由于这种依赖关系比较明显,程序员在维护代码的时候会很容易注意到这些关联,从而降低修改代码的风险。


用表格代替多条路径的选择 

        还有一些函数由于有很多条路径可供选择,每条路径(对应的是if…else语句的一个分支,或者是switch语句的一个case)包含多个语句,于是整个函数就会变得很长。让这种冗长且复杂的函数变简单,一个办法是用表格代替路径的选择。我们来看一个经典的例子,输入年份和月份,求该月份的天数。下面是基于switch的C#代码实现:

public static int GetDaysInMonth(int month, int year){    int days = 0;    switch(month)    {        case 1:        case 3:        case 5:        case 7:        case 8:        case 10:        case 12:            days = 31;            break;        case 4:        case 6:        case 9:        case 11:            days = 30;            break;        case 2:            if((year % 400 == 0) || (year % 100 != 0 && year % 4 == 0))            {                days = 29;            }            else            {                days = 28;            }            break;        default:            break;    }    return days;}

        一年虽然有12个月,但一个月的天数只有4中可能,所以实际上只有4个分支。如果分支更多,上述代码会更加冗长。
        我们再来看一种相对而言要简洁一些的实现:

public static int GetDaysInMonth(int month, int year){    int[,] days = {{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31},                   {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}};    int yearIndex = IsLeapYear(year) ? 1 : 0;    return days[yearIndex, month - 1];}private static bool IsLeapYear(int year){    if((year % 400 == 0) || (year % 100 != 0 && year % 4 == 0))    {        return true;    }    return false;}

        我们用了两种策略来降低代码的复杂度。一是我们把判断一年是不是闰年的代码抽出来放到一个单独的函数IsLeapYear。这样的好处是给复杂的逻辑判断一个有意义的名字,这样其他人即使不仔细分析逻辑判断的细节仅仅通过函数名就能知道代码的功能。这一点我们在前文中已经讨论了。二是把一年中每个月的天数放到一个数组中。由于有平年和闰年两种可能,因此我们用了一个二维数组。
        上述代码只是一个简单的例子。在实际开发中,用来存储信息的表格不仅仅是数组,也有可能是哈希表(在C#中为Dictionary)。表格中存储的不仅仅只是数值,也可能是其他信息,甚至还可能是回调函数,表示每个条件下对应的操作。
        假设我们开发一个养宠物狗的游戏。该游戏在一个星期的七天里给玩家不同的任务,比如星期一给狗喂狗粮、星期二给狗洗澡等等。为了实现这个功能,我们可以定义一个长度为7的数组,数组的元素分别对应喂狗粮、洗澡等回调函数。这样就可以很简单地把星期几对应到数组的下标选择合适的操作,从而避免一个冗长、复杂的if…else或者switch…case语句。


用设计模式代替多条路劲的选择

        回调函数在面向过程的编程语言如C中经常使用,但在面向对象的编程语言如C#和Java中不是最好的选择。我们可以基于继承和多态实现在不同条件下选择不同行为。下面我们简单地用C#实现宠物狗游戏在一周七天里有不同的玩法。
        首先我们定义一个接口IPlayMode,它里面只有一个函数Play:

public interface IPlayMode{    void Play();}
        接下来我们为一周的每一天定义一种玩法。比如周一是给宠物狗喂狗粮:

public class MondayPlayMode: IPlayMode{    public void Play()    {        Feed();    }    private void Feed()    {    }}
        周二给狗洗澡:
public class TuesdayPlayMode: IPlayMode{    public void Play()    {        Wash();    }    private void Wash()    {    }}
        其他几天的代码类似。在具体每天的玩法的类里,我们都实现接口Play,并调用具体玩法的函数。例如周一对应类的Play函数调用Feed函数用来给小狗喂狗粮,周二对应的类的Play函数调用Wash函数给狗洗澡等。
        接下来根据星期几选择一种玩法的代码就变得很简洁了,如下面的代码:
public void PlayGame(int date){    IPlayMode playMode = SelectMode(date);    playMode.Play();}IPlayMode[] playModes = { new MondayPlayMode(), new TuesdayPlayMode(), ... };private IPlayMode SelectMode(int date){    return playModes[date - 1];}
        我们定义一个长度为7的数组,其中的元素分别为一周七天对应的玩法。在选择玩法的时候,我们只需要把星期几对应到数组的下标,从数组中得到相应的玩法即可。
        现在我们设想如果采用直观的if…else或者switch…case来实现同样的功能。如果每一天对应的玩法需要15行代码,那么PlayGame将是一个超过100行的大函数。我们用把不同玩法的代码分散到不同的类型里去,然后通过表格驱动的方法选择对应的玩法。这样做的结果是每个函数都很简短,同时可读性和可维护性都很强。
        上述代码实际上是基于策略(Strategy)模式实现的。策略模式试用的场景是不同的条件下需要不同的策略或算法。由于我们的宠物狗游戏要求不同的日期有不同的玩法,非常适合策略模式。同时,状态(State)模式和策略非常类似,非常适合在不同状态下需要不同处理方式的场景。还是以宠物狗游戏为例,假设狗有不同的心情,并且不同心情它的动作也不同。比如它高兴的时候就会摇尾巴,它烦躁的时候就会大叫。我们可以把宠物狗的心情当作状态,用状态模式去实现不同心情时它的行为。

小结

        虽然每个程序员都知道高可读性和高可维护性代码的重要性,但编写具备高可读性和高可维护性的代码并不是一件容易的事情。很多人会选择通过添加注释来提高代码的可读性,然而这个方法不一定有效。当代码和注释不一致时,程序会让人十分困惑。
        通常把长的逻辑复杂的函数分解成若干个简短的函数能有效地提高代码的可读性和可维护性。函数过长通常是由于两个原因造成的。一是函数的步骤比较多,每一步都需要很多行代码。我们可以为每个步骤定义一个子函数。函数过长的另外一个原因是条件分支很多,每一个分支都有很多行代码。我们可以通过表格来取代条件分支。如果是用面向对象的编程语言,我们还可以应用策略模式或者状态模式把各个分支的代码分散到相应的子类里去。



9 0