介绍
在本文中,我们将了解为什么 switch 在 OOP 中几乎从来都不是一个好主意。我们将从不同的角度来研究它如何影响代码、测试和可维护性。之后,我们将对现有代码进行一些重构,并分析新重构代码的好处。我们将看到几乎任何(无论多长时间)开关都可以并且应该转换为优雅的单行。 本文中使用的所有源代码都可以在这里找到,它有两个分支 - 未重构的代码(主)和重构后的结果(功能/重构开关)。
switch几乎从来都不是一个好主意
有多少次你遇到一些代码,你需要添加一些更改或修改某些东西,你这样做?
对我来说,答案太多了!在我的职业生涯中,我遇到了相当多的类似该示例的代码。但是这个代码有什么问题呢?为什么它会在我们这么多人中引发这种反应?答案很简单:.我不会说开关总是一个坏主意,因为有些用例可能是合适的,但我会把重点放在在大多数其他情况下使用开关的缺点和缺点上。switch
因此,让我们深入研究一下:
- switch在OOP中几乎从来都不是一个好主意。它被认为是一种代码气味,因为随着时间的推移,它会强制执行长方法, 长类和重复的代码。Martin Fowler说:“复杂的条件逻辑是编程中最难推理的事情之一,所以我总是在寻找为条件逻辑添加结构的方法。
- R.C. Martin也提出了一些很好的观点:“很难做出一个小小的转换声明。即使只有两个情况的 switch 语句也比我想要的单个块或函数更大。也很难做出一个做一件事的开关声明。就其本质而言,switch 语句总是做 N 件事。switch
- switch语句明显违反了 OCP(开放-闭合原则),因为承载它的类/方法必须始终更改以适应新类型(在大多数情况下为枚举)。
- switch 语句直接违反了 SRP(单一责任原则),因为它强制类具有多个更改原因,并且显然,即使使用较小的开关,我们也有 N 个更改理由。
- 在极少数情况下,语句将与第一次编写时相同,但它通常倾向于不受控制地增长,无论是在事例数量上,还是在任务需要它的情况下,它会随着各种新调用或if/else语句而增长。switch
- 从测试的角度来看,单元测试每个案例的所有场景是相当困难的,特别是如果它有其他条件。不仅如此,它还会使托管它的类的测试类膨胀,从而难以跟踪正在发生的事情。有时,人们可能会遇到解决方案,例如在测试中使用循环或条件,以避免创建太多的测试用例,这又是一种气味(http://xunitpatterns.com/Conditional%20Test%20Logic.html),并且本身会带来许多其他缺点。
- switch陈述是Andy Hunt和Dave Thomas在他们的书《The Pragmatic Programmer》中讨论的The Broken Window Theory的一个很好的例子。如果 switch 语句没有在正确的时间重构,它将鼓励其他开发人员为其增长做出贡献,直到它成为一个更大的问题。
- 如果语句中包含大量案例和其他条件,则会增加认知复杂性(循环复杂性),使代码更难理解和维护。看看SonarSource对此有何评论:https://www.sonarsource.com/resources/cognitive-complexity/switch
- 有时,为了摆脱Sonar警告或具有更好的可读性,开发人员会提取许多私有方法,以便具有较小的案例。然后,很难跟踪正在发生的事情以及需要测试的内容。在我看来,肯特·贝克(Kent Beck)对此有一个很好的观点:“我只测试公共方法。如果私有方法足够复杂,需要测试,它通常需要自己的对象。因此,并非在所有情况下,但为了switch的可读性,许多逻辑被埋没在需要测试的私有方法中时,它可能会达到这一点。
- 我们甚至可以从内聚力和耦合的角度来考虑开关。我们的方法专注于太多的事情要做,并且与现有案例紧密耦合,我们总是朝着低耦合和高内聚力的方向发展,对吧?
好吧,现在该怎么办?
所以说得够多了。有了前面提出的论点,很明显我们需要做点什么。我们该怎么办?答案又很简单。重构它。
人们通常做的最明显的事情是将开关提取到私有方法,或者他们将开关案例提取到私有方法,但这并不能真正解决我们的问题,因为我们有开关及其所有缺点。我们甚至可以去使用一个公共方法提取一个类,该方法负责托管整个开关逻辑,但这仍然给我们留下了开关。
鲍勃叔叔和马丁·福勒都提到了使用多态行为而不是。通常,switch的行为可以很容易地被埋没到一个工厂中,负责选择正确的子类来托管案例行为。这更好,但我们仍然有开关,其大部分缺点都到位。在我看来,这是一个了不起的解决方案,但是为了完全摆脱开关,我们可以更进一步,将切换逻辑委托给接口的子类/实现器。因此,我们的工厂不需要按大小写逻辑托管交换机,它将简单地循环访问所有实现/子类,并根据实现中可用的信息为作业选择正确的任务。使用这种方法,我们可以从代码中消除开关。switch
编码时间
考虑到解决方案,让我们开始重构。为此,我创建了一个 Playground 项目,该项目将使我们能够看到将交换机重构为单行。该项目非常简单 - 它是关于一家公司,该公司有一些待售产品,客户拥有卡信息,并且能够处理这些产品的“付款”。
剧透预警: 这个项目绝不是一个真实的例子,说明如何完成,处理付款或类似的事情,它只是为了重构演示而创建的。很多事情本来可以更好地实现,或者在其他地方或以另一种方式实现。所以请记住,这是一个类似于我在职业生涯中在各种项目中看到的真实生产代码的例子。
重构的先决条件:
- 您需要单元测试。
- 如果您尝试重构的方法未经过单元测试,请执行检查点 1。
- 接受这样一种想法,即你必须编写比现在更多的代码。
- 对代码的专业态度。
现在一切都很清楚了。我们的整个魔力都将在PaymentServiceImpl中发生.java
在那里,我们有我们的开关,在方法中payforProduct
switch (client.getCard().getType()) {br case REWARDS -> {br updatedPrice = price + (price * REWARDS_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br REWARDS_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br if (client.isRewardEligible()) {br updatedPrice = price - (price * REWARDS_DISCOUNT);br log.info("Applied {} discount for client {} with card {} and price now = {} for product id {}",br REWARDS_DISCOUNT, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br }br case SECURED -> {br updatedPrice = price + (price * SECURED_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br SECURED_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br case LOW_INTEREST -> {br updatedPrice = price + (price * LOW_INTEREST_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br LOW_INTEREST_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br case CASHBACK -> {br updatedPrice = price + (price * CASHBACK_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br CASHBACK_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br if (client.getCard().isCashbackEnabled()) {br updatedPrice = price - (price * CASHBACK_DISCOUNT);br log.info("Applied {} discount for client {} with card {} and price now = {} for product id {}",br CASHBACK_DISCOUNT, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br }br case STUDENT -> {br if (client.getAge() >= 18 && client.getAge() <= 21) {br updatedPrice = price + (price * STUDENT_SCHOOL_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br STUDENT_SCHOOL_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br } else if (client.getAge() > 21 && client.getAge() <= 23) {br updatedPrice = price + (price * STUDENT_UNIVERSITY_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br STUDENT_UNIVERSITY_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br } else {br updatedPrice = price + (price * DEFAULT_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br DEFAULT_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br }br case TRAVEL -> {br if (product.getCategory() == Category.TRAVEL) {br updatedPrice = price + (price * TRAVEL_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br TRAVEL_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br } else {br updatedPrice = price + (price * DEFAULT_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br DEFAULT_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br }br case BUSINESS -> {br updatedPrice = price + (price * BUSINESS_COMMISSION) + (price * BUSINESS_TAX);br log.info("Applied {} commission and tax {} for client {} with card {} and price now = {} for product id {}",br BUSINESS_COMMISSION, BUSINESS_TAX, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br default -> {br updatedPrice = price + (price * DEFAULT_COMMISSION);br log.info("Applied default commission {} for client {} with card {} and price now = {} for product id {}",br DEFAULT_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br }
在这种情况下,负责在付款前根据卡类型使用应用的佣金更新产品的价格。在某些情况下,佣金取决于产品类别,或者当基于卡类型时,可能会应用一定的折扣,以提供一些现金返还和类似的东西。switch
注意:此方法经过单元测试,您可以从文件PaymentServiceImplTest运行测试.java以更好地了解正在发生的事情。此外,当我说switch语句倾向于膨胀测试时,您可以更好地了解我的意思,因为许多小测试涵盖了每种情况的每个场景。
因此,第一步是为每个案例中发生的逻辑引入类层次结构或协定。在这种情况下,我们将采用基于接口/实现的方法,因为我们只讨论行为。因此,我们将创建一个名为CommissionApplierStrategy
public interface CommissionApplierStrategy {br double applyCommission(Client client, Product product);br}
因此,该接口只有一种方法,该方法将负责根据客户的信息和产品应用佣金,然后返回更新的价格以进行进一步处理。但是,等一下,在我们继续之前,我们的工厂呢?它将如何决定选择哪种实现?再次切换?不,我们决定不进行转换。而不是让我们的工厂决定使用开关选择哪个实现,我们将让我们的实现告诉我们他们可以做什么,并让工厂简单地为工作选择正确的实现。为此,我们将强制我们的实现实现另一种方法
public interface CommissionApplierStrategy {br double applyCommission(Client client, Product product);br CardType getCardType();br}
现在我们有了 方法 ,这将迫使我们的实现告诉我们他们可以向哪张卡应用佣金。CardType getCardType()
有了这个界面,从这一点开始,一切都变得更容易。剩下要做的就是在特定实现中从每个事例中移动逻辑。我将只向您展示一个实现。您可以在 inc.evil.refactorswitch.service.commission 包中查看其余的内容。因此,假设我们选择第一个案例,该案例负责为奖励卡类型申请佣金。这就是我们的实现的样子:
@Slf4jbr@Servicebrpublic class RewardsCommissionApplierStrategyImpl implements CommissionApplierStrategy {br private static final double REWARDS_COMMISSION = 0.016;br private static final double REWARDS_DISCOUNT = 0.007;brbr @Overridebr public double applyCommission(Client client, Product product) {br double price = product.getPrice();br Long productId = product.getId();br double updatedPrice = price + (price * REWARDS_COMMISSION);br log.info("Applied {} commission for client {} with card {} and price now = {} for product id {}",br REWARDS_COMMISSION, client.getId(), client.getCard().getType(), updatedPrice, productId);br if (client.isRewardEligible()) {br updatedPrice = price - (price * REWARDS_DISCOUNT);br log.info("Applied {} discount for client {} with card {} and price now = {} for product id {}",br REWARDS_DISCOUNT, client.getId(), client.getCard().getType(), updatedPrice, productId);br }br return updatedPrice;br }brbr @Overridebr public CardType getCardType() {br return CardType.REWARDS;br }br}brbr
现在我们的班级做了一件事,并且做得很好;它适用于奖励卡类型的佣金。我们的代码现在可以很容易地单独测试,并作为未来请求中的功能独立增长。还要注意的是,我们的类,除了开关中有很多情况臃肿之外,还充满了每种佣金类型特有的常量。现在,在特定情况下使用的每个常量都可以安全地移动到其相应的实现中,就像我们在 中所做的那样。PaymentServiceImpl.javaRewardsCommissionApplierStrategyImpl
从这一点开始,我们必须将每个案例重构为其相应的实现,就像我们对第一个案例所做的那样。默认情况如何?我们应该如何处理这个问题?我们可以添加一个新的枚举,它将代表默认处理,类似于卡类型。这将允许我们创建另一个实现,该实现将涵盖switch语句中的默认情况。CardTypeSIMPLE
是的,在这一点上,我们所有的逻辑都被委托给特定的实现。剩下要做的是创建工厂/解析器。有了方法,以及Spring Framework对DI的一点魔力,我们可以做这样的事情。CardType getCardType();
@Servicebr@RequiredArgsConstructorbrclass CommissionApplierStrategyResolverImpl implements CommissionApplierStrategyResolver {brbr private final List<CommissionApplierStrategy> commissionApplierStrategies;br private final DefaultCommissionApplierStrategyImpl defaultCommissionApplierStrategy;brbr @Overridebr public CommissionApplierStrategy getCommissionApplier(CardType cardType) {br return commissionApplierStrategies.stream()br .filter(s -> s.getCardType() == cardType)br .findFirst()br .orElse(defaultCommissionApplierStrategy);br }br}brbr
因此,主要有两件事非常重要:CommissionApplierStrategyResolverImpl
- 通过列表对策略实现进行依赖注入。所以我们都知道依赖注入在春天是多么有用,我们都很好地适应了特定豆类@Autowired,但是你知道春天可以注入豆子甚至地图列表吗?这对我们的案例非常有用,可以注入所有实现并准备好进行查询。
- 没有Spring Framework在你身边不是问题,你可以在工厂手动进行注入,或者使用其他DI框架,如Google Guice。
- 方法将基于通过所有实现提供的流式处理返回特定实现,并询问谁是正确的人来完成工作。如果未找到任何实现,则将提供默认实现。这甚至可以配置为引发异常,如果这不是我们期望的行为。因此,这种方法与Spring的功率一起充当开关,除了我们没有大多数缺点开关拖拽。CommissionApplierStrategy getCommissionApplier(CardType cardType)CardType
现在,随着解析器的实现和每个案例的所有实现,我们的方法变成这样:payForProductPaymentServiceImpl
@Overridebr public void payForProduct(Long productId, Client client) {br Product product = productRepository.findById(productId)br .orElseThrow(() -> new ProductNotFoundException(String.format("Product with id %s not found", productId)));brbr double updatedPrice = commissionApplierResolver.getCommissionApplier(client.getCard().getType()).applyCommission(client, product);brbr PaymentResponse paymentResponse = paymentClient.debitCard(client.getCard(), updatedPrice);br if (paymentResponse.isSuccess()) {br product.setStatus(ProductStatus.PAID);br //process delivery, etcbr log.info("Product {} paid with success", productId);br } else {br //alert the user & other business logicbr log.warn("Product {} payment failed with [{}]", productId, paymentResponse.getErrorMessage());br throw new ProductPaymentException(paymentResponse.getErrorMessage());br }br
我们的整个开关被简化为优雅的单线。很可爱,不是吗?
结论
最后,在重构之后,让我们缩小并看看我们做了什么:
- 我们将一个非常大的代码块减少到一行,这很可能使我们摆脱了长方法和长类。
- 我们摆脱了声纳警告
- 我们为OCP(添加另一个案例是实现接口的问题 - 没有人必须受苦)和SRP(每个类/方法都有自己的责任)伸张正义
- 我们分离了代码,使其更具凝聚力
- 我们的新实现是易于更改(ETC),并遵循正交性原则
- 不再有破窗理论
- 每个新实现都有自己的一套测试,使PaymentServiceImpl的测试套件泄气。
- 我们的代码中没有大或丑陋的私有方法