在C#中删除Switch-Case语句并使用模式匹配的教程

377 阅读10分钟

网上有很多关于switch-case语句的争论。似乎有一半的程序员认为switch-case语句实际上是一种反模式,而另一半程序员则声称这一概念实际上是有用途的。通常,第二组人试图证明一个观点:在一些简单的情况下,使用switch-case是可以的,比如一些非常简单的检查。我的经验告诉我另一种情况,我完全属于第一组。

1.switch-case语句的问题

在这些所谓的 "简单情况 "下,这些语句往往会失控,我们通常会得到一大块无法阅读的代码。更不用说如果需求发生变化(我们知道它们会发生变化),语句本身就必须修改,从而破坏了开放-封闭原则。这种情况在企业系统中尤其明显,这种想法会导致维护地狱。

switch-case语句(就像if-else语句)的另一个问题是,它们将数据和行为结合在一起,这让我们想起了程序化编程。这意味着什么,为什么它是坏的?好吧,数据的本质是不包含行为的信息。 将数据视为行为,并将其用于控制你的应用程序的工作流程,是创造所提到的维护地狱的原因。例如,看一下这段代码:

string data = "Item1";

var action1 = new Action(() => { Console.Write("This is one!"); });
var action2 = new Action(() => { Console.Write("This is two!"); });
var action3 = new Action(() => { Console.Write("This is three!"); });

switch (data)
{
    case "Item1":
        action1();
        break;
    case "Item2":
        action2();
        break;
    default:
        action3();
        break;
}

这段代码中的数据是名为data的变量,但同时数据也是打印在控制台的字符串。行为是在控制台打印信息的动作,同时行为也是将数据集中的信息集映射到一些动作上,即将 "项目1 "映射到动作1,"项目2 "映射到动作2,等等。

Programming Visual

正如你所看到的,数据是可以被改变的,而行为是不应该被改变的,把它们混在一起就是问题的根源。

那么,如何摆脱switch-case语句的困扰呢?

2.从switch-case到面向对象的代码

最近,我正在处理依赖switch-case语句的代码。所以,我根据这个现实世界的问题和我重构它的方式创建了下一个例子:

public class Entity
{
    public string Type { get; set; }

    public int GetNewValueBasedOnType(int newValue)
    {
        int returnValue;
        switch (Type)
        {
            case "Type0":
                returnValue = newValue;
                break;
            case "Type1":
                returnValue = newValue * 2;
                break;
            case "Type2":
                returnValue = newValue * 3;
                break;
        }

        return newValue;
    }
}

在这里,我们有一个带有属性Type的实体类。这个属性定义了函数GetNewValueBasedOnType将如何计算其结果。这个类是以这种方式使用的:

var entity = new Entity() { Type = "Type1" };
var value = entity.GetNewValueBasedOnType(6);

Coding Visual

为了将这个例子修改成适当的面向对象的代码,我们需要改变相当多的东西。我们可以注意到的第一件事是,即使我们有多个实体类型,它们仍然只是 实体 类型。一个甜甜圈就是一个甜甜圈,不管它是什么味道。这意味着我们可以为每个实体类型创建一个类,而所有这些类都应该实现一个抽象类。另外,我们还可以为实体类型定义一个枚举。这看起来像这样:

public enum EntityType
{
    Type0 = 0,
    Type1 = 1,
    Type2 = 2
}

public abstract class Entity
{
    public abstract int GetNewValue(int newValue);
}

实体类的具体实现看起来是这样的:

public class Type0Entity : Entity
{
    public override int GetNewValue(int newValue)
    {
        return newValue;
    }
}

public class Type1Entity : Entity
{
    public override int GetNewValue(int newValue)
    {
        return 2*newValue;
    }
}

public class Type2Entity : Entity
{
    public override int GetNewValue(int newValue)
    {
        return 3*newValue;
    }
}

这就好多了。现在我们更接近于真正的面向对象的实现,而不仅仅是一些花哨的程序性实现。我们使用了所有那些面向对象编程为我们提供的好概念,比如抽象和继承。

Data Visual

不过,还是有一个问题,那就是如何使用这些类。似乎我们并没有删除switch-case语句,我们只是把它从Entity类移到了我们将创建具体Entity实现的对象的地方。基本上,我们仍然需要确定我们需要实例化哪个类。在这一点上,我们可以制作Entity Factory。

public class EntityFactory
{
    private Dictionary<EntityType, Func<entity>> _entityTypeMapper;

    public EntityFactory()
    {
        _entityTypeMapper = new Dictionary<entitytype, func<entity="">>();
        _entityTypeMapper.Add(EntityType.Type0, () => { return new Type0Entity(); });
        _entityTypeMapper.Add(EntityType.Type1, () => { return new Type1Entity(); });
        _entityTypeMapper.Add(EntityType.Type2, () => { return new Type2Entity(); });
    }

    public Entity GetEntityBasedOnType(EntityType entityType)
    {
        return _entityTypeMapper[entityType]();
    }
}

现在,我们可以像这样使用这段代码:

try
{
    Console.WriteLine("Enter entity type:");
    var entytyType = (EntityType)Enum.Parse(typeof(EntityType), Console.ReadLine(), true);

    Console.WriteLine("Enter new value:");
    var modificationValue = Convert.ToInt32(Console.ReadLine());

    var entityFactory = new EntityFactory();
    var entity = entityFactory.GetEntityBasedOnType(entytyType);
    var result = entity.GetNewValue(modificationValue);

    Console.WriteLine(result);
    Console.ReadLine();
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}

很多时候,我把这段代码给别人看,会得到一个评论说这太复杂了。但是,问题是,这才是真正的面向对象的代码应该有的样子。它对变化更有弹性,其行为与数据分离。数据不再控制工作流程。我们可以看到,实体类型和输入值是可以改变的,但它们不受行为的干扰。通过这样做,我们提高了代码的可维护性。这段代码的下一个进化步骤将是使用策略模式。

我通常得到的另一个抱怨是,这段代码仍然没有完全满足Open-Close原则,也就是说,如果需要添加一个新的实体类型,我们需要改变实体工厂类本身。这是一个非常好的抱怨,而且说得很有道理。尽管如此,我们不应该忘记,满足SOLID原则的代码是我们应该一直努力的,但很多时候我们不能完全达到这个目的。在我看来,Open-Close原则是所有SOLID原则中最难以达到的。

3.C#中的模式匹配--演变

你可能会问自己为什么这一章会在这里。我们正在讨论switch-case语句,那么模式匹配与此有什么关系?好吧,记得我说过,我认为不应该使用switch-case吗?事情是这样的,几年前,C# 7引入了模式匹配功能。这个功能在函数式编程语言中已经很成熟了,比如F#,它在很大程度上依赖于switch-case语句,所以让我们花点时间再深入了解一下,看看这是否能改变我的想法。

模式匹配毕竟是什么?为了更好地解释这个问题,我将使用F#的例子。F#有一个强大的概念,叫做判别联合。使用这个概念,我们可以定义一个变量,它可以有多种类型,这意味着不仅值是可以改变的,而且类型也是可以改变的。等等,什么?这里有一个例子:

type Shape =
    | Rectangle of width : float * length : float
    | Circle of radius : float

Programming Visual

这意味着我们有一个定义的形状类型,它可以是一个矩形或一个圆形。shape类型的变量可以是这两种类型中的任何一种。就像薛定谔的猫一样,在我们仔细观察之前,我们不会知道该变量的类型。现在,让我们假设我们想写一个函数,以获得形状的高度。这看起来会是这样的:

let getShapeHeight shape =
    match shape with
    | Rectangle(width = h) -> h
    | Circle(radius = r) -> 2. * r

这里发生的事情是,我们检查了变量shape的类型,如果形状是矩形类型,则返回矩形宽度,如果形状是圆形类型,则返回两倍的半径值。我们已经打开了盒子,折叠了一个波函数。

虽然这个概念起初看起来不必要地复杂,但实际上它既优雅又强大。控制工作流给我们带来了很多可能性,所以在当年,当我看到它将成为C#的一部分时,我非常高兴。 那么在改进之前,这段代码在C#中是怎样的呢?

var shapes = new Dictionary<string, object>
{
    { "FirstShape", new Rectangle(1, 1) },
    { "SecondShape", new Circle(6) }
};

foreach(var shape in shapes)
{
    if (shape.Value is Rectangle)
    {
        var height = ((Rectangle)shape.Value).Height;
        Console.WriteLine(height);
    }
    else if (shape.Value is Circle)
    {
        var height = ((Circle)shape.Value).Radius * 2;
        Console.WriteLine(height);
    }
}

3.1 C# 7中的模式匹配

这个功能在C#中给我们带来的是简化这个语法的能力。它也为switch语句提供了更多的可用性,也就是说,现在我们可以通过变量的类型进行切换。

foreach (var shape in shapes)
{
    switch(shape.Value)
    {
        case Rectangle r:
            Console.WriteLine(r.Height);
            break;
        case Circle c:
            Console.WriteLine(2 * c.Radius);
            break;
    }
}

现在可以做的另一件事是使用when子句。

foreach (var shape in shapes)
{
    switch(shape.Value)
    {
        case Rectangle r:
            Console.WriteLine(r.Height);
            break;
        case Circle c when c.Radius > 10:
            Console.WriteLine(2 * c.Radius);
            break;
    }
}

这意味着我们可以在case条件下检查对象的特定属性。现在,随着C#新版本的出现,这个功能确实得到了改善,所以这只是模式匹配的一个开始。让我们来看看。

Data Visual

3.2 C# 8中的模式匹配

在C#8中,我们得到了switch-case表达式。这个功能旨在进一步提高开关大小写的可用性,并且与模式匹配配合得非常好。我的意思是,真的很好。这两个功能一起给了我们写这样的代码的能力。

var message = shape switch 
{
	Circle cir => "This is a circle with area {cir.Area}!",
	Rectange rec when rec.height==rec.width => "This is a square!",
	{ Area : 111 } => "Area is 111",
	_ => "This is a shape!"
}

所以,通过这种方法,我们有更多的控制权。我们可以检查类型,特定类型的属性值,基本类型的属性值,等等。然而,请注意,我们不能说*{Area > 111},*我们必须真正具体地说明这个值。所以,这是一个很大的进步,但仍然没有达到目的。

3.3 C# 9中的模式匹配

C# 9正好改善了我在上一节中提到的问题。然而,这并不是唯一的改进。微软的人听取了社区的反馈,改进了模式匹配,所以我们可以做到这一点:

var message = shape switch 
{
	Circle cir => "This is a circle with area {cir.Area}!",
	Rectangle rec when rec.height == rec.width => "This is a square!",
	Circle cir { Area : > 111 and < 222} => "This is a Circle with specific area value",
	{ Area == 111 } => "Area is 111",
	_ => "This is a shape!"
}
    
var areaMessage = shape.Area switch
{
    > 111 and < 222 => "This is a specific area",
    _ => "Nothing specific about the area"
}

这就是很多事情的关键所在。你可以使用像 "和 "和 " "这样的运算符来匹配特定的模式。至少在我眼里,这是模式匹配和切换大小写的一个重大飞跃。

Programming Visual

3.4 C# 10中的模式匹配

在C#10中,微软继续改进模式匹配,让我们有能力检查对象中的对象。例如,如果我们像这样扩展Shape类。

public abstract class Shape
{
	public abstract double Area { get; }
  
	public Shape ShapeWithinShape { get; set; }
}

那么,我们就添加了一个属性,代表形状内的形状。这可以像这样与模式匹配一起使用。

if (shape is Rectangle { ShapeWithinShape.Area == 111 })
{
	// Do some amaizing stuf
}

3.5 C# 11中的模式匹配

在模式匹配方面,C# 11有一些改进。例如,在C# 11中,你可以 用常量字符串 匹配 SpanReadOnlySpan 。另外,利用.NET技术,数据科学项目的工作变得更加容易,因为有了Lists模式匹配的功能。有趣的是,在以前的 Python 版本中也增加了类似的功能 。在这里了解更多关于C# 11的功能。

总结

说实话,现在我可以看到switch-case语句又爬回了我的代码中。当然,它是以表达式和模式匹配的形式出现的。我仍然坚决反对开箱即用的switch-case。然而,看到模式匹配多年来的发展,我的脑海中涌现出许多想法。

我在这篇文章中没有涉及的是策略模式,在某些情况下,如果你想从你的代码中删除switch-case,这也是一个不错的选择。