软件设计的哲学
- Published on
观点一:对复杂性的零容忍
在书的第二章中,作者讨论了什么是复杂性以及复杂性的症状:
- 变更放大:一个简单的变更需要在多个不同的地方进行修改。
- 认知负担:开发人员在完成任务时需要学习大量的内容。
- 未知的未知:并不明显哪些代码需要修改才能完成任务。
作者认为,复杂性并不是由单个错误引起的,它是逐渐积累的。有时我们会说,某些地方的小小复杂性不会造成太大影响,但如果项目中的每个人都这样想,复杂性将迅速增长。
“为了减缓复杂性的增长,必须采取零容忍的态度。”
例如,假设有一个简单的订单处理系统,它需要计算运费并应用折扣。但该系统的设计不合理,逻辑重复,导致变更放大。例如,CheckoutService
和ShippingService
都用相同的逻辑来计算折扣,但这些逻辑被分别实现:
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;
}
}
为什么这样不好?
变更放大:如果想修改折扣应用方式(例如引入新折扣或更改标准),必须修改
CheckoutService
和ShippingService
。认知负担:开发人员必须记住更新所有涉及折扣的地方。如果忘记更新某个地方(例如遗漏了
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);
}
}
}
为什么这样不好?
- 不必要的拆分:
ValidateUser
、SaveUserToDatabase
和SendWelcomeEmail
方法太细化了,而且总是按照严格的顺序一起使用。拆分这些步骤为多个方法增加了不必要的接口。 - 增加认知负担:开发人员现在需要跟踪多个方法,而这些方法虽然紧密相关,但被不必要地拆分开来。过度拆分增加了理解注册过程的复杂性。
- 信息重叠:这三个方法都直接与用户注册过程相关,彼此共享相同的用户对象,并且总是一起调用。很难仅凭其中一个方法理解整个过程。
如何改进?
简单地说,我们可以将这些方法“内联”,如下所示:
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
块,我们将多个相关的异常处理集中在一个地方,减少了异常处理程序的数量。
结论
《软件设计的哲学》强调,复杂性是软件系统的隐形杀手,它通过看似小的决定逐渐积累。书中描述了复杂性的症状以及如何应对它们。在这篇博客中,我分享了三个让我印象深刻的观点,但书中还有许多其他有价值的见解,强烈建议大家亲自阅读。