代码重构之[过长函数]

引言

优秀的函数通常保持简洁。

  1. 刻意控制函数长度有助于剔除不属于该功能模块的冗余代码,从而提升代码的可读性和健壮性。
  2. 短小且功能明确的函数,有助于提升命名的准确性。而好的命名可以协助读者快速理解其作用,而无需逐行查看代码,减少了理解成本。

有经验的程序员通常坚持每个函数的行数不超过50行,这种原则对我产生了深远的影响。

然而,理想与现实总是存在差距。尽管短函数的优点明显,但在实际项目中,我们仍然会遇到一些过长的函数。为了更好地处理这一问题,我阅读了《重构》一书,并总结了以下三个关键问题:

  1. 如何判断一个函数是否过长?
  2. 函数过长会造成什么负面影响?
  3. 如何修改和重构「过长函数」?

特征

暗中作祟的函数命名

在《代码重构之神秘命名》中我们提到:为取名而付出的纠结,往往能够促使我们去修改设计上的缺陷。

如果一个函数的名称是 A ,但实际上包含了 A B 两部分功能,那剔除 B 这一部分代码,能够有效的精简函数长度。实际上,造成函数变得越来越长,越来越臃肿的原因,大多数情况下,就是因为函数的功能不够单一。

因此,函数功能不单一,不仅代表函数名称不够准确,也可能代表该函数的长度还可以再精简一些。

观察上下文中的注释

我们遵循一条原则:每当感到需要使用注释来说明什么的时候,我们就应该把需要说明的代码部分写进一个独立的函数中,并以其用途(而非实现手法)命名。

因此,我们可以将函数中出现的注释,视为一种函数过长的信号。


为什么重构?

  1. 长函数提高了理解成本,当需要维护长函数时,会花费更多的时间去定位需要修改的部分。而短函数更具阐释力。
  2. 长函数的内部常常会杂糅许多计算功能,当我们修改其中一处代码时,还需要确保这不会影响到其他部分,如果上下文存在依赖关系,那修改后还会扩大测试范围。而短函数更易于维护。

如何重构?

提炼函数

我们观察到下面示例代码中,存在一句"打印info中所需信息"的注释,这代表注释之下的代码,可能与注释之上的代码逻辑不属于一个逻辑模块,这是一个重构的信号。

scss 复制代码
public class Example
{
    public void PrintCharacterInfo()
    {
        PrintTitle("该角色信息如下:");

        var character = QueryCurCharacter((c) => c.state == CharacterState.Select);
        var characterPrintInfo = character.GetPrintInfo();
        //打印info中所需信息
        Console.WriteLine(characterPrintInfo.nickName);
        Console.WriteLine(characterPrintInfo.sex);
        Console.WriteLine(characterPrintInfo.skinName);
        Console.WriteLine(characterPrintInfo.hairColor);
    }
}
scss 复制代码
public class Example
{
    public void PrintCharacterInfo(Character character)
    {
        PrintTitle("该角色信息如下:");

        var character = QueryCurCharacter((c) => c.state == CharacterState.Select);
        var characterPrintInfo = character.GetPrintInfo();
        PrintInfo(characterPrintInfo);
    }

    public void PrintInfo(CharacterPrintInfo printInfo)
    {
        Console.WriteLine(characterPrintInfo.nickName);
        Console.WriteLine(characterPrintInfo.sex);
        Console.WriteLine(characterPrintInfo.skinName);
        Console.WriteLine(characterPrintInfo.hairColor);
    }
}

以查询替代临时变量,再提炼

下面的例子是原作者编写的示例代码(原著中将查询的过程封装成函数,但C#中有更好用的"属性")。使用"查询"替代"临时变量"最大的好处是:

  • 可以使"查询的过程"与原函数之间形成明显的边界。
  • "查询的过程"函数可以多次复用。

使用"临时变量"的场景是:那些只被使用一次,且后续逻辑不会再变更的变量。如果这个变量可能被使用多次,或者这个变量后续可能会被修改,则推荐你使用"查询函数"。

csharp 复制代码
public float GetFinalPrice()
{
    var basePrice = itemCount * itemPrice;
    if (basePrice > 1000)
    {
        return basePrice * 0.95f;
    }
    else
    {
        return basePrice * 0.98f;
    }
}
csharp 复制代码
public float BasePrice
{
    get => itemCount * itemPrice;
}

public float GetFinalPrice()
{
    if (BasePrice > 1000)
    {
        return BasePrice * 0.95f;
    }
    else
    {
        return BasePrice * 0.98f;
    }
}

以命令取代函数

"命令"的定义: 函数,不管是独立函数,还是以方法(method)形式附着在对象上的函数, 是程序设计的基本构造块。不过,将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我称之为"命令对象"(command object),或者简称"命 令"(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。

以命令取代函数的好处是,我们可以将复杂的计算,以更加灵活的方式呈现出来,对于这个命令对象,我们甚至可以添加更多的相关操作,比如取消操作。特别是为了应对单元测试、多场景调用的情况,命令对象总是能够灵活的满足上述需求,将命令发起者和命令接收者分隔开。

但命令对象本身就是复杂的,编写一个命令对象的代码量远多于函数。在大多数情况下,能够使用函数尽量使用,只有在普通函数无法满足需求,且不得不使用命令对象,且需要将函数的发起方和接收方解耦时,我才会选择它。

下面是一个命令对象取代函数的简单例子:

csharp 复制代码
public float GetScore(Student stu, ScoreRule rule)
{
    var result = 0f;

    //复杂的计算
    
    return result;
}
csharp 复制代码
public float GetScore(Student stu, ScoreRule rule)
{
    return new StudentScore(stu, rule).Get();
}

public class StudentScore
{
    private Student stu;
    private ScoreRule rule;
    
    public StudentScore(Student stu, ScoreRule rule)
    {
        this.stu = stu;
        this.rule = rule;
    }

    public float Get()
    {
        //复杂的计算
    }
}

以多态取代分支表达式

多态是面向对象的基本特征之一,但这并不代表所有的分支表达式都应该用多态替代。如果你有如下两种情况,原作者推荐使用多态。

  1. 每个条件下的逻辑复杂。
  2. 在多个地方反复调用此分支表达式。

下面是一个非常典型的示例。当我们需要多个地方、多次调用 GetCarColor 方法时,可以将此拆分成不同的 Car 类,每个类都可以实现自己的 GetCarColor 方法

typescript 复制代码
public Color GetCarColor(CarType type)
{
    switch(type)
    {
        case CarType.XiaoMi:
        {
            return Color.Orange;
        }
        case CarType.BMW:
        {
            return Color.White;
        }
        case CarType.AuDi:
        {
            return Color.Black;
        }
    }
}
kotlin 复制代码
public interface Icolor
{
    Color GetColor(){}
}

public class XiaoMiCar : IColor
{
    public Color GetColor()
    {
        return Color.Orange;
    }
}

public class BMWCar : IColor
{
    public Color GetColor()
    {
        return Color.White;
    }
}

public class AuDiCar : IColor
{
    public Color GetColor()
    {
        returnColor.Black;
    }
}

拆分循环

拆分循环的意义在于,将两段不同的逻辑在形式上拆分开。原作者认为,一个循环只为了做一件事,这样后来的读者能够很快速的、清晰的了解到这段循环的作用。

而且原作者还提到了,如果觉得拆分循环会对性能上造成压力,那么就在循环被拆分后,再去做性能优化,这是两件事,不能一起做。

下面是一个简单例子:

ini 复制代码
public void UpdateStudentInfos()
{
    for	(int i = 0; i < studentList.Count; i++)
    {
        UpdatePersonInfo(studentList[i].personInfo);
        UpdateScoreInfo(studentList[i].scoreInfo);
    }
}
ini 复制代码
public void UpdateStudentInfos()
{
    for	(int i = 0; i < studentList.Count; i++)
    {
        UpdatePersonInfo(studentList[i].personInfo);
    }

    for	(int i = 0; i < studentList.Count; i++)
    {
        UpdateScoreInfo(studentList[i].scoreInfo);
    }
}
相关推荐
这个DBA有点耶21 小时前
ELT 工具选型完整指南:从 ETL 差异分析到工程落地
数据库·架构·代码规范
这个DBA有点耶1 天前
分布式事务深度:TCC空回滚、悬挂与Saga补偿风暴的根因与解法
数据库·mysql·代码规范
这个DBA有点耶3 天前
为什么UPDATE比SELECT危险得多?锁范围等于扫描行数
数据库·架构·代码规范
沈麽鬼3 天前
纸上代码
代码规范
深圳老胡5 天前
STM32CubeMX 生成 CMake 工程,用 VSCode 编译与调试的完整流程
笔记·stm32·单片机·代码规范
星星泡饭吃5 天前
【信息安全】越权防护设计评审指南
代码规范
GlobalSign数字证书5 天前
申请 EV 代码签名证书需要准备哪些材料?
代码规范
深圳老胡5 天前
STM32F4 OTA 升级双 App 方案设计与实现
笔记·stm32·单片机·代码规范
深圳老胡5 天前
STM32F4 OTA升级:单App与双App方案优缺点对比
笔记·stm32·单片机·代码规范
这个DBA有点耶6 天前
Change Buffer深入:二级索引写入的隐形加速器与它的代价
数据库·mysql·代码规范