重构类以消除switch case

34

假设我有一个类似这样的类,可以计算不同交通工具在不同距离下的旅行成本:

public class TransportationCostCalculator
{
    public double DistanceToDestination { get; set; }

    public decimal CostOfTravel(string transportMethod)
    {
        switch (transportMethod)
        {
            case "Bicycle":
                return (decimal)(DistanceToDestination * 1);
            case "Bus":
                return (decimal)(DistanceToDestination * 2);
            case "Car":
                return (decimal)(DistanceToDestination * 3);
            default:
                throw new ArgumentOutOfRangeException();
        }
    }
这很好,但是从维护角度来看,switch案例可能会成为一场噩梦。如果我将来想要使用飞机或火车怎么办?那么我必须更改上面的类。有什么替代switch案例可以在这里使用,并有任何提示吗?
我想象在像这样的控制台应用程序中使用它,该应用程序将从命令行运行,其中包含所需的交通工具类型和您要旅行的距离的参数:
class Program
{
    static void Main(string[] args)
    {
        if(args.Length < 2)
        {
            Console.WriteLine("Not enough arguments to run this program");
            Console.ReadLine();
        }
        else
        {
            var transportMethod = args[0];
            var distance = args[1];
            var calculator = new TransportCostCalculator { DistanceToDestination = double.Parse(distance) };
            var result = calculator.CostOfTravel(transportMethod);
            Console.WriteLine(result);
            Console.ReadLine();
        }
    }
}

非常感谢任何提示!


4
不要使用字符串来表示transportMethod,可以尝试创建一个枚举。 - VivekDev
这是一个非常流行的话题。摆脱switch语句。我认为在这种情况下,switch语句很好;)解决这个问题的六个答案之一是记录。然而,程序员最喜欢做的事情就是这样:) - blogprogramisty.net
1
你添加“airplane”的机会有多大,而不必因其他原因在别处重新编译/部署应用程序?即使只是文档方面的问题?-- 基于Dictionary的答案看起来不错,但请注意它们将异常点与传输定义点分离。您的switch会在那里抛出ArgumentOutOfRangeException,显然缺少“airplane”-- 您甚至可以在异常消息中提供该信息...(并不是说您或任何答案都是错误的。只是从不同的角度看待它。) - DevSolar
2
我认为你需要从架构的角度来思考。如果在交通工具选择方面,每英里成本是唯一的考虑因素,那么请继续使用你的switch-case语句。但是,如果还有其他考虑因素,以至于你在其他地方有另一个基于交通工具的条件语句(例如,“你有燃油收据吗?”),那么你应该考虑一个MeansOfTransport抽象基类,并将Bus/Car/Bicycle作为填充这些细节的具体类。 - Graham
你可以使用枚举和一点反射来将用户字符串解析为元素名称。 - beppe9000
显示剩余4条评论
11个回答

39

你可以这样做:

public class TransportationCostCalculator {
    Dictionary<string,double> _travelModifier;

    TransportationCostCalculator()
    {
        _travelModifier = new Dictionary<string,double> ();

        _travelModifier.Add("bicycle", 1);
        _travelModifier.Add("bus", 2);
        _travelModifier.Add("car", 3);
    }


    public decimal CostOfTravel(string transportationMethod) =>
       (decimal) _travelModifier[transportationMethod] * DistanceToDestination;
}

你可以将运输类型及其修饰符存储在配置文件中,而不是使用switch语句。我将其放在构造函数中以示例,但它可以从任何地方加载。我还可能会使Dictionary静态,并且仅在加载一次。如果在运行时不需要更改,则没有必要每次创建新的TransportationCostCalculator时都重新填充它。

如上所述,以下是如何通过配置文件加载它:

void Main()
{
  // By Hard coding. 
  /*
    TransportationCostCalculator.AddTravelModifier("bicycle", 1);
    TransportationCostCalculator.AddTravelModifier("bus", 2);
    TransportationCostCalculator.AddTravelModifier("car", 3);
  */
    //By File 
    //assuming file is: name,value
    System.IO.File.ReadAllLines("C:\\temp\\modifiers.txt")
    .ToList().ForEach(line =>
        {
           var parts = line.Split(',');
        TransportationCostCalculator.AddTravelModifier
            (parts[0], Double.Parse(parts[1]));
        }
    );
    
}

public class TransportationCostCalculator {
    static Dictionary<string,double> _travelModifier = 
         new Dictionary<string,double> ();

    public static void AddTravelModifier(string name, double modifier)
    {
        if (_travelModifier.ContainsKey(name))
        {
            throw new Exception($"{name} already exists in dictionary.");
        }
        
        _travelModifier.Add(name, modifier);
    }
    
    public double DistanceToDestination { get; set; }

    TransportationCostCalculator()
    {
        _travelModifier = new Dictionary<string,double> ();
    }


    public decimal CostOfTravel(string transportationMethod) =>
       (decimal)( _travelModifier[transportationMethod] * DistanceToDestination);
}

编辑:评论中提到,如果方程需要更改而不更新代码,则无法修改该方程,因此我在此编写了一篇文章介绍如何实现:https://kemiller2002.github.io/2016/03/07/Configuring-Logic.html


3
我喜欢这种方法胜过将代码重构成一个新的设计模式,因为它更快、更容易,且开销更小。 - mike
2
虽然您的回答确实符合OP的要求,但我必须问一下 - 这比switch语句更易于维护吗?本质上,“case X:return Y”只是被“_travelModifier.Add(X,Y)”替换了,这对我来说似乎是相同的维护工作量? - dmeglio
7
一旦你测试通过配置文件添加它可行(我的示例是在构造函数中添加i),你可以添加、修改、删除表格条目而不需要重新编译代码。这样代码就能像以前一样工作,因为它没有改变。 - kemiller2002
4
@Khaine775 一个简单的文本文件,其中包含键值对,就可以实现: "bicycle=1\nbus=2\ncar=3" - user439793
2
这确实可行,但我认为使用一个带有乘数命名属性的小结构体或类更加明确和易读。此外,如果新类型不使用距离的简单乘数,而是使用其他计算方法,那么策略/工厂就会胜出。 - ventaur
显示剩余5条评论

35

据我看来,基于你当前的方法的任何解决方案在一个关键的方面都存在缺陷:无论怎样处理,你都将数据放入了代码中。这意味着每当你想更改其中任何数字、添加一个新的车辆类型等时,你都必须编辑代码,然后重新编译、发布补丁等。

你真正应该做的是将那些数据放到它应该属于的地方——一个单独的非编译文件中。你可以使用XML、JSON、某种形式的数据库,甚至只是一个简单的配置文件。如果你想的话,可以对其进行加密,但不一定需要。

然后,你只需要编写一个解析器读取该文件并创建车辆类型到成本乘数或其他你想要保存的属性的映射。添加新车辆就像更新你的数据文件一样简单。无需编辑代码或重新编译等。如果你打算在未来添加东西,这种方式更为强大且易于维护。


5
这是一个非常重要的观点,说得很好。代码用于代码,数据文件用于数据。尽量保持它们分开。 - Floris
2
数据驱动的解决方案可以减少单元测试的代码块数量。您不必把所有东西都放在配置文件中,但正如@Floris所提到的那样,重要的是分离。 - mungflesh
这是我个人认为应该被接受的答案。在这里,代码和数据的分离是最重要的。 - TheIronKnuckle
1
这是一个很好的建议...但同时需要有细微差别。在代码中拥有数据(例如作为“枚举”)可以进行编译时检查,这也有其优点;而适当的配置处理(如果配置错误,则具有可读性的错误)也有成本(开发和维护),因此总是存在权衡。 - Matthieu M.

14

看起来这是一个很好的依赖注入(dependency-injection)的候选方案:

interface ITransportation {
    decimal CalcCosts(double distance);
}

class Bus : ITransportation { 
    decimal CalcCosts(double distance) { return (decimal)(distance * 2); }
}
class Bicycle : ITransportation { 
    decimal CalcCosts(double distance) { return (decimal)(distance * 1); }
}
class Car: ITransportation {
    decimal CalcCosts(double distance) { return (decimal)(distance * 3); }
}

现在,您可以轻松地创建一个新的类Plane:
class Plane : ITransportation {
    decimal CalcCosts(double distance) { return (decimal)(distance * 4); }
}

现在为您的计算机创建一个构造函数,该构造函数需要一个ITransportation实例。 在CostOfTravel方法中,您现在可以调用ITransportation.CalcCosts(DistanceToDestination)
var calculator = new TransportationCostCalculator(new Plane());

这种设计的优点是,您可以在不更改TransportationCostCalculator类的任何代码的情况下交换实际的交通工具实例。

要完成这个设计,您还可以创建一个TransportationFactory如下:

class TransportationFactory {
    ITransportation Create(string type) {
        switch case "Bus": return new Bus(); break
        // ...
}

你需要调用它的方式如下

ITransportation t = myFactory.Create("Bus");
TransportationCostCalculator calculator = new TransportationCostCalculator(t);
var result = myCalculator.CostOfTravel(50);

3
问题仍然存在:如何确定要实例化哪个ITransportation类(公交车,自行车,飞机等)? - Matt
3
通常情况下,使用工厂方法会包括一个switch语句 :) 这样仍然会返回那个结果。 - dmeglio
3
是的,工厂将有这个开关,但它只在一个地方。想象一下,当你将来想要添加另一种计算成本的方法时,使用原始方法会导致更多的开关语句,但使用工厂则不会。 - ventaur

8
您可以像这样定义一个抽象类,并让每个“TransportationMethod”扩展抽象类:
abstract class TransportationMethod {
    public TransportationMethod() {
        // constructor logic
    }

    abstract public double travelCost(double distance);
}

class Bicycle : TransportationMethod {
    public Bicycle() : base() { }

    override public double travelCost(double distance) {
        return distance * 1;
    }
}

class Bus : TransportationMethod {
    public Bus() : base() { }

    override public double travelCost(double distance) {
        return distance * 2;
    }
}

class Car : TransportationMethod {
    public Car() : base() { }

    override public double travelCost(double distance) {
        return distance * 3;
    }
}

因此,在您实际的方法调用中,它可以被重写为:

public decimal CostOfTravel(TransportationMethod t) {
    return t.travelCost(DistanceToDestination);
}

请纠正我,但抽象类不能有构造函数。 - Matt
请纠正语法。C#中没有super - romanoza
1
感谢大家对抽象构造函数进行的指正。我已经更新了自己的代码,现在更加流畅了。 - Matt
3
我认为继承是更好的选择。虽然使用字典也可以,但继承能更好地扩展,并且可以处理后期引入的类型(虽然可以稍后将类型添加到字典中,但需要一个专门用于添加交通模式的接口)。当交通方式之间存在更多差异(速度等)时,继承也能更好地扩展。 - Peter - Reinstate Monica
@BlueRaja-DannyPflughoeft,没问题。我只是想把它放在他当前的代码背景下,并展示它如何解决他的问题,即重构对switch的需求。 - TayTay
显示剩余2条评论

5
你可以为每种旅行类型使用一个策略类。但是,那么你可能需要一个工厂来基于交通方式创建策略,这很可能需要一个switch语句以返回适当的计算器。
    public class CalculatorFactory {
        public static ICalculator CreateCalculator(string transportType) {
            switch (transportType) {
                case "car":
                    return new CarCalculator();
                ...
public class CarCalculator : ICalculator {
    public decimal Calc(double distance) {
        return distance * 1;
    }
}
....

4
你可以创建一个基于运输方式返回乘数的字典
public class TransportationCostCalculator
{
    Dictionary<string, int> multiplierDictionary;

    TransportationCostCalculator () 
    {
         var multiplierDictionary= new Dictionary<string, int> (); 
         dictionary.Add ("Bicycle", 1);
         dictionary.Add ("Bus", 2);
         ....
    }

    public decimal CostOfTravel(string transportMethod)
    {
         return  (decimal) (multiplierDictionary[transportMethod] * DistanceToDestination);       
    }

2
我认为答案是某种类型的数据库。
如果您使用某个数据库,TransportCostCalculator会向该数据库请求给定运输方式的乘数。
数据库可以是文本文件、XML或SQL服务器。它只是一个键值对。
如果您想仅使用代码,则无法避免从运输方式到乘数(或成本)的转换。因此需要某种类型的开关。
通过使用数据库,您可以将字典从代码中分离出来,并且不必更改代码以应用新的运输方式或更改值。

3
似乎过度了。你为一个非常简单的计算添加了文件 IO 和/或网络 IO 的开销。你提出使用 SQL 数据库可以达到 C# 中 Dictionary<> 相同的效果,只是将键值对存储在外部资源中而不是 C# 中。 - dmeglio
2
@dman2306 我很感激你的评论,因为它指出了审查项目要求的重要性。哪个更重要:避免I/O还是避免需要重新编译?我本质上喜欢外部文本文件/数据库方法,因为它避免了一旦设置就触及代码方法。但是,在几次向代码中添加遗漏的交通方式之后,您可能还需要添加多少(因此需要重新编译)?之后,它是在代码中的,并且正如我正在学习的和你指出的那样,速度要快得多。 - GG2
如果你担心I/O带来的性能损失,那么你做错了。你应该在程序启动时加载文件并将数据保存在内存中。如果每次调用函数都要查看文件,那当然会很慢。你想这样做的唯一原因是A.) 数据文件非常大,无法一次性全部装入内存,或者B.) 你期望数据在程序运行时频繁更改。但似乎这两种情况都不是。 - Darrel Hoffman

1
这是策略设计模式的一个案例。创建一个基类,比如说TravelCostCalculator,然后为你考虑的每种旅行方式开发一个类,每个类都覆盖了一个公共方法Calculate(double)。然后,你可以使用工厂模式根据需要实例化特定的TravelCostCalculator
关键在于如何构建工厂(不使用switch语句)。我做法是通过一个静态类构造函数(public static Classname() - 不是实例构造函数)来将每个策略类注册到一个Dictionary<string, Type>中的工厂中。
由于C#不像C++大多数情况下运行类构造函数是确定性的,你必须显式地运行它们以确保它们会运行。这可以在主程序或工厂构造函数中完成。缺点是如果你添加一个策略类,你也必须将其添加到要运行的构造函数列表中。你可以创建一个必须运行的静态方法(TouchRegister),也可以使用System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor
class Derived : Base
{
    public static Derived()
    {
        Factory.Register(typeof(Derived));
    }
}

// this could also be done with generics rather than Type class
class Factory
{
    public static Register(Type t)
    {
        RegisteredTypes[t.Name] = t;
    }
    protected Dictionary<string, Type t> RegisteredTypes;

    public static Base Instantiate(string typeName)
    {
        if (!RegisteredTypes.ContainsKey(typeName))
            return null;
        return (Base) Activator.CreateInstance(RegisteredTypes[typeName]);
    }
}

2
不必处理静态构造函数,您可以使用反射来查找从TravelCostCalculator继承的类。然后,您可以在每个类上使用自定义属性来指定名称,例如[TravelMethod("Bus")]。或者您可以按照惯例进行操作,只需将派生类命名为METHODTravelCostCalculator,其中METHOD是您要搜索的名称。 - dmeglio

1
我更喜欢使用Enum,像这样:

public enum TransportMethod
{
    Bicycle = 1,
    Bus = 2,
    Car = 3
}

并像这样使用它的方法:

public decimal CostOfTravel(string transportMethod)
{
    var tmValue = (int)Enum.Parse(typeof(TransportMethod), transportMethod);
    return DistanceToDestination  * tmValue;
}

请注意上述方法区分大小写,因此您可以将第一个字符大写

相关答案


0

虽然之前已经提到过,但我想再谈一下相关话题。

这是一个很好的反思例子。 “Reflection对象用于在运行时获取类型信息。给予访问正在运行的程序元数据的类位于System.Reflection命名空间中。”

通过使用反射,如果需要添加另一种开关类型(例如火车),您将避免编译代码。您可以通过使用配置文件即时解决问题。

最近我通过使用依赖注入解决了类似的问题,但仍然会出现switch语句。这种方法无法解决您的问题。Tyson建议的方法仍然需要重新编译,如果字典中添加了新类型。

以下是我所说的内容示例: 使用C#中的反射动态加载自定义配置XML: http://technico.qnownow.com/dynamic-loading-of-custom-configuration-xml-using-reflection-in-c/


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接