logo

软件设计的哲学

Published on

观点一:对复杂性的零容忍

在书的第二章中,作者讨论了什么是复杂性以及复杂性的症状:

  • 变更放大:一个简单的变更需要在多个不同的地方进行修改。
  • 认知负担:开发人员在完成任务时需要学习大量的内容。
  • 未知的未知:并不明显哪些代码需要修改才能完成任务。

作者认为,复杂性并不是由单个错误引起的,它是逐渐积累的。有时我们会说,某些地方的小小复杂性不会造成太大影响,但如果项目中的每个人都这样想,复杂性将迅速增长。

“为了减缓复杂性的增长,必须采取零容忍的态度。”

例如,假设有一个简单的订单处理系统,它需要计算运费并应用折扣。但该系统的设计不合理,逻辑重复,导致变更放大。例如,CheckoutServiceShippingService都用相同的逻辑来计算折扣,但这些逻辑被分别实现:

public class CheckoutService
{
    public decimal CalculateTotal(Order order)
    {
        decimal total = order.Items.Sum(item => item.Price);

        // 应用折扣逻辑
        if (order.CouponCode == "SUMMER2024")
        {
            total -= 10;
        }

        return total;
    }
}

public class ShippingService
{
    public decimal CalculateShipping(Order order)
    {
        decimal shippingCost = order.ShippingAddress.Country == "US" ? 5 : 15;

        // 应用重复的折扣逻辑
        if (order.CouponCode == "SUMMER2024")
        {
            shippingCost -= 10;
        }

        return shippingCost;
    }
}

为什么这样不好?

  • 变更放大:如果想修改折扣应用方式(例如引入新折扣或更改标准),必须修改CheckoutServiceShippingService

  • 认知负担:开发人员必须记住更新所有涉及折扣的地方。如果忘记更新某个地方(例如遗漏了ShippingService中的更新),将导致不一致的行为。

  • 未知的未知:如果让新开发人员去更新折扣逻辑,可能不知道折扣逻辑在多个地方都有实现,可能只修改了一个地方,导致运费计算出现错误。

如何改进?

我们可以通过将折扣逻辑封装到一个单独的服务中来消除重复逻辑。这样一来,如果折扣逻辑需要更改,我们只需要修改一个地方,从而减少整体复杂性。

public class DiscountService
{
    public decimal ApplyDiscount(Order order, decimal total)
    {
        if (order.CouponCode == "SUMMER2024")
        {
            return total - 10;
        }
        return total;
    }
}

public class CheckoutService
{
    private readonly DiscountService _discountService;

    public CheckoutService(DiscountService discountService)
    {
        _discountService = discountService;
    }

    public decimal CalculateTotal(Order order)
    {
        decimal total = order.Items.Sum(item => item.Price);

        // 通过集中服务应用折扣
        total = _discountService.ApplyDiscount(order, total);

        return total;
    }
}

public class ShippingService
{
    private readonly DiscountService _discountService;

    public ShippingService(DiscountService discountService)
    {
        _discountService = discountService;
    }

    public decimal CalculateShipping(Order order)
    {
        decimal shippingCost = order.ShippingAddress.Country == "US" ? 5 : 15;

        // 通过集中服务应用折扣
        shippingCost = _discountService.ApplyDiscount(order, shippingCost);

        return shippingCost;
    }
}

总结:在第一个例子中,我们看到一个简单的折扣逻辑变更需要修改多个服务。通过将逻辑集中在DiscountService中,我们消除了重复,简化了系统的维护和演化。

观点二:更小的组件不一定更好

“给定两个功能,它们应该合并实现,还是应该将它们分离开来?”——这是第9章的主题。

作者认为,在减少系统复杂性时,更小的组件并不总是对模块化有利,并列出了将功能分拆成更多组件的几个缺点:

  • 一些复杂性来源于组件数量的增加
  • 拆分可能导致更多代码来管理这些组件
  • 拆分使得开发人员更难同时看到这些组件,甚至不知道它们的存在
  • 拆分可能导致重复

作者还提出了一些合并两个代码片段的指示:

  • 它们共享信息。
  • 它们是一起使用的,必须是双向的。例如,如果每次我使用方法A时,总是同时使用方法B,那么这些方法应该合并。
  • 它们在概念上有重叠,即有一个简单的更高层次类别包含了这两个代码片段。
  • 如果不同时查看,它很难理解其中一个代码片段。

作者提到常见的“清洁技巧”:“任何超过X行的函数都应该拆分。”他接着补充说,“仅仅因为长度长就拆分方法通常不是一个好理由。”方法拆分会引入额外的接口,增加复杂性。“除非它使整体系统更简单,否则不应该拆分方法。”

例如,假设我们有一个用户注册过程。开发人员将逻辑拆分成多个方法,分别处理验证用户、将用户保存到数据库以及发送欢迎邮件。虽然每个方法都有自己的功能,但它们都共享信息且概念上是相关的。过度拆分导致了不必要的复杂性和开销。

public class UserService
{
    public bool ValidateUser(User user)
    {
        if (string.IsNullOrEmpty(user.Email))
        {
            throw new ArgumentException("Email is required.");
        }
        return true;
    }

    public void SaveUserToDatabase(User user)
    {
        Database.Save(user);
    }

    public void SendWelcomeEmail(User user)
    {
        EmailService.Send("Welcome to our platform!", user.Email);
    }

    public void RegisterUser(User user)
    {
        if (ValidateUser(user))
        {
            SaveUserToDatabase(user);
            SendWelcomeEmail(user);
        }
    }
}

为什么这样不好?

  • 不必要的拆分ValidateUserSaveUserToDatabaseSendWelcomeEmail方法太细化了,而且总是按照严格的顺序一起使用。拆分这些步骤为多个方法增加了不必要的接口。
  • 增加认知负担:开发人员现在需要跟踪多个方法,而这些方法虽然紧密相关,但被不必要地拆分开来。过度拆分增加了理解注册过程的复杂性。
  • 信息重叠:这三个方法都直接与用户注册过程相关,彼此共享相同的用户对象,并且总是一起调用。很难仅凭其中一个方法理解整个过程。

如何改进?

简单地说,我们可以将这些方法“内联”,如下所示:

public class UserService
{
    public void RegisterUser(User user)
    {
        if (string.IsNullOrEmpty(user.Email))
        {
            throw new ArgumentException("Email is required.");
        }

        Database.Save(user);

        EmailService.Send("Welcome to our platform!", user.Email);
    }
}

总结:仅仅为了让方法变小而拆分功能实际上会增加复杂性。通过合并那些共享信息并且总是一起使用的步骤,我们减少了拆分的开销,简化了接口,使代码更易于理解和维护。

观点三:异常处理增加了复杂性

在第10章中,作者指出“异常处理是软件系统复杂性的最大来源之一。”

异常处理有两种方式:

  • 完成正在进行的工作(例如网络包丢失?重新发送;数据损坏?从快照中恢复)。
  • 中止操作并向上抛出异常

作者提到,中止操作可能会增加复杂性。例如,如果一个数据结构已经部分初始化,但发生了异常,“异常处理代码必须恢复一致性,例如通过回滚在异常发生前所做的更改。”

作者指出,抛出异常并让调用者处理它很容易,也很诱人。但他认为,作为某个方法的开发者,如果你自己处理异常有困难,那么调用者处理时也未必能应对得了。

“减少异常处理造成的复杂性的最好方法是减少需要处理异常的地方。”

接下来,作者分享了一些减少异常处理方法的技巧:

  • 定义错误不存在:最好通过设计API来避免异常的产生。
  • 屏蔽异常:在系统的低层次检测并处理异常,使得高层次的软件不需要关心这些问题。
  • 异常聚合:将多个异常集中在一个地方处理,而不是为每个单独的异常编写处理程序。

例如,下面的代码中,我们在多个地方处理了不同的异常,导致了重复的代码和复杂性。

public class FileProcessor
{
    public void ProcessFile(string filePath)
    {
        try
        {
            var config = ReadConfigFile(filePath);
            var processedData = ProcessData(config);
            WriteDataToFile(processedData, filePath);
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"Config file not found: {ex.Message}");
            throw;
        }
        catch (IOException ex)
        {
            Console.WriteLine($"I/O error: {ex.Message}");
            throw new ApplicationException("I/O failure during file processing", ex);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Unexpected error: {ex.Message}");
            throw;
        }
    }
}

为什么这样不好?

  • 过多的异常处理程序:在代码的不同部分有多个try-catch块,导致了重复和复杂性。每个方法都有自己的错误处理逻辑,异常被抛到上层,却没有处理核心问题。
  • 过度中止ProcessFile方法将某些异常(如FileNotFoundException)的处理责任传递给调用者,增加了复杂性。调用者可能不知道如何处理这些错误,将它们向上传递,导致系统中增加了更多的异常处理程序。

如何改进?

修复1:定义错误不存在
我们可以重新设计ReadConfigFile方法,使缺少文件时不抛出异常,而是返回默认配置。

private Config ReadConfigFile(string filePath)
{
    if (!File.Exists(filePath))
    {
        Console.WriteLine("Config file not found, using default settings.");
        return Config.GetDefaultConfig();  // 默认行为,不抛出异常
    }

    return new Config(filePath);
}

为什么这样更好?

  • 不需要异常处理:通过将缺少文件定义为可接受的条件,我们完全避免了抛出FileNotFoundException
  • 简化代码:调用者不需要处理文件缺失问题,它只需在找不到文件时获得默认配置。

修复2:屏蔽异常
我们可以在低层次内部处理异常,这样高层次代码就不需要担心它们。

private void WriteDataToFile(ProcessedData data, string filePath)
{
    // 屏蔽异常:通过重试来避免抛出异常
    int retryCount = 3;
    while (retryCount > 0)
    {
        try
        {
            File.WriteAllText(filePath, data.ToString());
            break;
        }
        catch (IOException)
        {
            retryCount--;
            if (retryCount == 0)
            {
                Console.WriteLine("Failed to write after retries. Aborting.");
                // 根据系统要求,可能需要抛出异常
                // 或者保存数据到临时文件,稍后再尝试写入
            }
        }
    }
}

为什么这样更好?

  • 通过重试来屏蔽异常:我们尝试重试写操作,最多3次。这屏蔽了IOException,即暂时的文件系统问题,避免了高层次代码关注这些异常。

修复3:异常聚合
我们将多个相关的异常集中在一个地方处理,而不是为每个异常编写独立的处理程序。

public void ProcessFile(string filePath)
{
    try
    {
        var config = ReadConfigFile(filePath);
        var processedData = ProcessData(config);
        WriteDataToFile(processedData, filePath);
    }
    catch (IOException ex) when (ex is FileNotFoundException || ex is UnauthorizedAccessException)
    {
        // 聚合异常处理程序:处理所有I/O相关的异常
        Console.WriteLine($"I/O failure: {ex.Message}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Unexpected error: {ex.Message}");
        throw;
    }
}

为什么这样更好?

  • 聚合异常:我们将所有与I/O相关的异常(如文件未找到或权限不足)都集中处理,避免了重复的错误处理逻辑。
  • 简化代码:不需要为每个异常编写多个catch块,我们将多个相关的异常处理集中在一个地方,减少了异常处理程序的数量。

结论

《软件设计的哲学》强调,复杂性是软件系统的隐形杀手,它通过看似小的决定逐渐积累。书中描述了复杂性的症状以及如何应对它们。在这篇博客中,我分享了三个让我印象深刻的观点,但书中还有许多其他有价值的见解,强烈建议大家亲自阅读。