使用反射替换 C# switch(enum) 控制流
硬编码的枚举非常好用,直到你需要用它们来进行程序流程控制。
引言
作为一名 C# 开发者,我非常喜欢枚举。它们简单易用且类型安全。它们代表了所表示对象的已知属性。然而,当枚举用于 `switch` 语句中的程序流程控制时,它们的有用性就开始下降。写出每个枚举并将其链接到需要调用的函数会很繁琐且容易出错。
虽然我并非主张消除使用枚举进行程序流程控制,但有时程序的枚举可能会变得难以管理,这可能是由于不良实践或组织要求迫使程序超出其原始范围。虽然并非真正必需,但这项技术可以替换掉占用大量屏幕空间的大型 switch 语句。
我们都见过(也许也做过)这样的代码:
switch (myEnum)
{
case myEnum.CaseA:
// handle case Alpha
case myEnum.CaseB:
// handle case Beta
// and so on...ad infinitum
}
真正的问题在于,当一个新的 `enum` 被添加到基于大型枚举进行分支的 switch 语句中时。其副作用可能是多方面的,并且如果代码深处隐藏着多个 `switch(enum)` 语句,一旦疏忽遗漏了一个不常用的枚举,将难以追踪。我努力研究了一种方法,以消除或最小化使用 `enum` 类型进行流程控制所带来的意外影响。
背景
我一直在扩展我在网络协议方面的知识,以及如何在网络上建立运行在不同计算机之间的进程通信。像许多其他刚接触网络通信的程序员一样,我决定从一个简单的客户端-服务器配置的聊天应用程序开始。本文中代码的必要性源于需要使用于流程控制的枚举更容易管理。
我使用 `enum` 类型来定义要由其他函数处理的已翻译消息的消息类型。每种消息类型都需要自己的处理方法,因此最初的程序使用 `switch(MessageType){case...}` 语句来处理少量消息类型。随着消息类型数量的增长,更新 switch 的必要性也随之增长。每添加一种新类型,出错的机会也会增加。
简化这一过程的下一步是创建一个 `Dictionary<MessageType, Action<<byte[]>>` 来将每种消息类型映射到其处理程序。我看了看,知道一定有更好的方法来映射函数,这样我就不必为每个新函数或消息类型进行更新。这就是结果:
使用代码
我能设想到的最简单、最不容易出错的方法,就是使用 `System.Reflection` 来映射一个包含许多成员的 enum 类型及其各种处理方法。通过反射,我能够使用自定义的 `Attribute` 来修饰负责处理每个 `enum` 类型代码分支的方法,从而完全消除了对大型 switch...case 语句的需求。使用反射会占用大量资源。建议如果使用此技术,应谨慎使用。
在本例中,我将使用一个非常简单的程序,它移动一个点,并将点的最新位置和移动方向报告给控制台。
我将首先定义枚举以及将用于修饰函数的 `Attribute`,以便它们能被映射到正确的 `enum`。
// just the basic cardinal directions for now
public enum Direction { GoUp, GoLeft, GoRight, GoDown }
[AttributeUsage(AttributeTargets.Method)]
public class MappedMethodAttribute : Attribute
{
public Direction Direction { get; set; }
public MappedMethodAttribute(Direction direction)
{
Direction = direction;
}
}
在这里,我们定义了我们的枚举和一个自定义属性,它允许编译器在某些方法上看到一个标记,即使在运行时也是如此。我们可以利用这一点。
接下来,我定义了一个类,它将包含要映射的函数,一种调用它们的方式,并使用 `Reflection` 在实例化类时创建方法映射。
class MappedMethodExample
{
private Dictionary<Direction, Func<Point, string>> _mappedMethods;
public MappedMethodExample()
{
_mappedMethods = new Dictionary<Direction, Func<Point, string>>();
MapMethods();
}
private void MapMethods()
{
foreach (MethodInfo mInfo in typeof(MappedMethodExample).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance))
{
object[] attributes = mInfo.GetCustomAttributes(true);
foreach (object attribute in attributes)
{
var mappedMethodAttr = attribute as MappedMethodAttribute;
if (mappedMethodAttr != null)
{
Direction dir = mappedMethodAttr.Direction;
var methodToMap = (Func<Point, string>)Delegate.CreateDelegate(typeof(Func<Point, string>), this, mInfo);
_mappedMethods.Add(dir, methodToMap);
}
}
}
}
[MappedMethod(Direction.GoDown)]
private string HandleMove_Down(Point newPosition)
{
return String.Format("You moved down. Now located at: {0}", newPosition.ToString());
}
[MappedMethod(Direction.GoLeft)]
private string HandleMove_Left(Point newPosition)
{
return String.Format("You moved left. Now located at: {0}", newPosition.ToString());
}
[MappedMethod(Direction.GoRight)]
private string HandleMove_Right(Point newPosition)
{
return String.Format("You moved right. Now located at: {0}", newPosition.ToString());
}
[MappedMethod(Direction.GoUp)]
private string HandleMove_Up(Point newPosition)
{
return String.Format("You moved up. Now located at: {0}", newPosition.ToString());
}
public string HandleMove(Direction direction, Point position)
{
try
{
return _mappedMethods[direction].Invoke(position);
}
catch (KeyNotFoundException)
{
throw;
}
}
}
关于这里正在发生的事情的一些细节。
乍一看,这里有很多事情在发生。这个类的目的是提供一个地方来存放将被映射到特定枚举的方法,同时将每个路径的处理方式安全地隐藏在 `private` 方法中。该类包含一个泛型 `Dictionary`,它有一个 `enum` 键和一个映射到该键的 `Func`。真正的魔力始于修饰方法处理程序的 `Attribute`。正如你所见,每种方法都通过其 `Attribute` 与特定的枚举相关联。
下一段魔术发生在 `MapMethods` 方法中。
首先,我们使用反射仅获取带有我们自定义属性修饰的方法。
我们使用 **NonPublic** 和 **Instance** 的绑定标志来告诉运行时,我们只想检查该类实例的私有方法。
接下来,就是简单地使用 foreach 循环遍历每个方法。在每个方法中,我们检查它的自定义属性,看看其中一个是否是我们的 `MappedMethodAttribute`。如果是,该属性告诉我们将其映射到哪个方向,然后将其对应的函数添加到映射中。
接下来的这段代码让我有些 trouble
这所做的是创建一个 `Delegate`(可以理解为函数指针)来指向被我们自定义属性修饰的方法。
让我感到困惑的是,你必须包含 `this`,这样 `Delegate.CreateDelegate` 才知道要指向 mapper 类中的函数。
收尾
这是我编写的用于测试的简单控制台程序。
class Program
{
static void Main(string[] args)
{
var methodHandler = new MappedMethodExample();
Point myLocation = new Point(0, 0);
myLocation.X += 1;
Console.WriteLine(methodHandler.HandleMove(Direction.GoUp, myLocation));
myLocation.Y += 1;
Console.WriteLine(methodHandler.HandleMove(Direction.GoRight, myLocation));
myLocation.X -= 1;
Console.WriteLine(methodHandler.HandleMove(Direction.GoDown, myLocation));
myLocation.Y -= 1;
Console.WriteLine(methodHandler.HandleMove(Direction.GoDown, myLocation));
}
}
关注点
我喜欢这种方法的一点是,你可以编写一个使用枚举进行流程控制的程序,并获得它们带来的所有类型安全性,同时将所有控制方法限制在调用堆栈的较高层。添加一个新的枚举(这似乎总是会发生)可以通过一个单一的入口点来处理所有需要处理新枚举的代码,从而相对轻松。枚举到方法映射的添加是自动发生的。
在这个例子的上下文中,假设我想为其他基数方向添加新的枚举。编写处理新枚举的代码就像下面这样简单:
[MappedMethod(Direction.NW)]
private string HandleMove_NorthWest(Point newPosition)
{
return String.Format("You moved north-west. Now located at: {0}", newPosition.ToString());
}
这个方法的另一个小细节是,你可以轻松地扩展你的类以包含 `Func` 委托的集合,所有这些委托都会被调用。
甚至有可能编写一个新的 `Attribute` 来标记包含也需要处理枚举特定代码路径的方法的类,并在启动时使用反射自动映射这些类和方法。
历史
初次撰写:2017/8/16
修订:2017/8/18 - 清理了一些语言并添加了更多细节