枚举字段属性的实用工具
枚举字段通常需要在 UI 上显示或输出到某些持久化存储时映射到键和人类可读的名称
引言
这是一个用 C# 以简单形式定义枚举及其关联数据的想法。将枚举值与相关信息进行映射需要大量的代码,并且会使枚举定义代码变得混乱。本文提供了一种解决方案,通过利用属性的力量,使此类代码更简单、更直观、更具可读性。
问题
当定义一个具有有限数量有效值的属性时,我们会使用枚举。从本质上讲,这使得代码中的这些值具有可读性,但不足以作为键名,也足以向用户显示。例如,当存在以下键值及其显示标题时:
键 | 标题 |
PS-4 | 索尼 PlayStation 4 |
XBOX-ONE | 微软 Xbox One |
SWITCH | 任天堂 Switch |
键不是数字值,因此它们不能是枚举值,并且有些键名不能是枚举值名称,因为它们包含连字符。因此,我们需要有方法将enum
值转换为键,并将键转换回enum
值。
当将它们显示给用户作为属性的选项时,显示enum
值名称对用户体验来说并不好。我们还需要为每个enum
值提供一个标题。
以下测试代码模拟了用户首先收到一个选项列表并选择其中一个,然后系统从所选选项的键中获取一个枚举值。
[TestFixture]
public class EnumUtilityTest
{
[Test]
public void Test()
{
var options = GameConsoleUtility.GetAll();
var optionDisplay = options
.ToDictionary(o => o.ToKey(), o => $"{o.ToCaption()} [{o.ToKey()}]");
var selectedKey = "XBOX-ONE";
var enumValue = selectedKey.ToGameConsole();
Assert.That(enumValue, Is.EqualTo(GameConsole.XboxOne));
}
}
背景
在处理此问题时,我希望在解决方案中实现以下几点,以提高代码的可读性,并相应地提高可维护性:
- 为每个关联数据命名
这不仅能阐明枚举声明代码中每个关联数据的目的,还能轻松地在您数百万行的代码中查找特定关联数据的用法/引用。
- 尽可能通用化逻辑
正如我希望为关联数据命名一样,每个枚举声明都需要自定义代码。为了简化每个代码,共享通用代码至关重要。
在我们深入研究我的解决方案之前,让我们先看看其他两种尝试。它们也解决了问题,但我并不喜欢它们,这就是我写这篇技巧的原因。它们可能过于无意义,但请仅将它们视为与后续解决方案的对比。
一种尝试
以下实现可以通过使用 3 组一对一映射来满足需求。但我不喜欢它。
在这种实现中,如果我们想添加一个新的枚举值,我们需要确保在其他两个部分添加一行,如果我们与许多开发人员在一个团队中工作,这可能会出错。此外,它的可读性不高,因为每个枚举值的映射定义是分开的。
public enum GameConsole
{
PS4,
XboxOne,
Switch
}
public static class GameConsoleUtility
{
static Dictionary<gameconsole, string="">
_keyMapping = new Dictionary<gameconsole, string="">
{
{ GameConsole.PS4, "PS-4" },
{ GameConsole.XboxOne, "XBOX-ONE" },
{ GameConsole.Switch, "SWITCH" },
};
static Dictionary<string, gameconsole=""> _antiKeyMapping;
static Dictionary<gameconsole, string="">
_captionMapping = new Dictionary<gameconsole, string="">
{
{ GameConsole.PS4, "Sony PlayStation 4" },
{ GameConsole.XboxOne, "Microsoft Xbox One" },
{ GameConsole.Switch, "Nintendo Switch" },
};
public static string ToKey(this GameConsole enumValue)
{
string key;
if (!_keyMapping.TryGetValue(enumValue, out key))
throw new Exception($"No mapping is specified for {enumValue.ToString()}");
return key;
}
public static GameConsole ToGameConsole(this string key)
{
if (_antiKeyMapping == null)
{
_antiKeyMapping = new Dictionary<string, gameconsole="">();
foreach (var pair in _keyMapping)
_antiKeyMapping.Add(pair.Value, pair.Key);
}
GameConsole enumValue;
if (!_antiKeyMapping.TryGetValue(key, out enumValue))
throw new Exception($"Invalid key for GameConsole: {key}");
return enumValue;
}
public static string ToCaption(this GameConsole enumValue)
{
string caption;
if (!_captionMapping.TryGetValue(enumValue, out caption))
throw new Exception($"No mapping is specified for {enumValue.ToString()}");
return _captionMapping[enumValue];
}
public static List<gameconsole> GetAll()
{
return _keyMapping.Keys.ToList();
}
}
另一种尝试
下面的实现将单独的映射合并为一组映射。我认为这更好,因为代码更简单,数据命名更清晰。但是,我仍然不喜欢它。
尽管如此,与枚举声明相比,与关联数据的映射还是有点偏离。此外,每个枚举类型都有这么多代码对我来说并不理想。
public enum GameConsole
{
PS4,
XboxOne,
Switch
}
public static class GameConsoleUtility
{
internal class GameConsoleMapping
{
public GameConsole EnumValue { get; set; }
public string Key { get; set; }
public string Caption { get; set; }
public GameConsoleMapping(GameConsole gameConsole, string key, string caption)
{
EnumValue = gameConsole;
Key = key;
Caption = caption;
}
}
static List<gameconsolemapping> _mappings = new List<gameconsolemapping>
{
new GameConsoleMapping(GameConsole.PS4, key: "PS-4", caption: "Sony PlayStation 4"),
new GameConsoleMapping(GameConsole.XboxOne, key: "XBOX-ONE", caption: "Microsoft Xbox One"),
new GameConsoleMapping(GameConsole.Switch, key: "SWITCH", caption: "Nintendo Switch"),
};
public static string ToKey(this GameConsole enumValue)
{
var key = _mappings.FirstOrDefault(m => m.EnumValue == enumValue)?.Key;
if (key == null)
throw new Exception($"No mapping is specified for {enumValue.ToString()}");
return key;
}
public static GameConsole ToGameConsole(this string key)
{
var enumValue = _mappings.FirstOrDefault(m => m.Key == key)?.EnumValue;
if (!enumValue.HasValue)
throw new Exception($"Invalid key for GameConsole: {key}");
return enumValue.Value;
}
public static string ToCaption(this GameConsole enumValue)
{
var caption = _mappings.FirstOrDefault(m => m.EnumValue == enumValue)?.Caption;
if (caption == null)
throw new Exception($"No mapping is specified for {enumValue.ToString()}");
return caption;
}
public static List<gameconsole> GetAll()
{
return _mappings.Select(m => m.EnumValue).ToList();
}
}
因此,我需要一个解决方案,它更加
- 更具可读性和直观性
- 每个枚举类型的代码更简单
解决方案
所以,对于解决方案,我需要
- 此类数据映射代码靠近枚举类型声明
- 每个枚举的代码更简单
对于我的第一个愿望,我认为使用属性是理想的。我尝试尽可能地通用化代码,并提出了以下解决方案。
让我们先看看我提取的通用代码。
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public class AliasAttribute : Attribute
{
public string[] Aliases { get; }
public AliasAttribute(params string[] aliases)
{
Aliases = aliases;
}
public string this[int i]
{
get
{
if (Aliases.Length > i)
return Aliases[i];
return null;
}
}
}
public static class AttributeUtility
{
class AliasMapping
{
public IConvertible EnumValue { get; }
public AliasAttribute Aliases { get; }
public AliasMapping(IConvertible enumValue, AliasAttribute aliases)
{
EnumValue = enumValue;
Aliases = aliases;
}
}
static Dictionary<Type, IEnumerable<AliasMapping>>
_mappingsMap = new Dictionary<Type, IEnumerable<AliasMapping>>();
static IEnumerable<T> getCustomAttributes<T, U>(this U enumValue)
where T : Attribute
where U : struct, IConvertible
{
var fieldName = enumValue.ToString();
return typeof(U).GetField(fieldName).GetCustomAttributes<T>(true);
}
static IEnumerable<AliasMapping> getMappings<T>() where T : struct, IConvertible
{
IEnumerable<AliasMapping> mappings;
if (!_mappingsMap.TryGetValue(typeof(T), out mappings))
{
mappings = Enum.GetValues(typeof(T)).Cast<T>().Select
(e => e.getCustomAttributes<AliasAttribute, T>().Select
(a => new AliasMapping(e, a)).FirstOrDefault());
_mappingsMap.Add(typeof(T), mappings);
}
return mappings;
}
public static string ToAlias<T>(this T enumValue, int index) where T : struct, IConvertible
{
var mapping = enumValue.getCustomAttributes<AliasAttribute, T>().FirstOrDefault();
if (mapping == null)
throw new Exception($"No mapping is defined for {enumValue.ToString()}");
return mapping[index];
}
public static T ToEnum<T>(this string alias, int index) where T: struct, IConvertible
{
var mapping = getMappings<T>().FirstOrDefault(m => m.Aliases[index] == alias);
if (mapping == null)
throw new Exception($"Invalid alias for {nameof(T)}: {alias}");
return (T)mapping.EnumValue;
}
public static List<T> GetAll<T>() where T : struct, IConvertible
{
return Enum.GetValues(typeof(T)).Cast<T>().ToList();
}
}
拥有这样的通用代码,这是枚举定义周围的代码。
public enum GameConsole
{
[KeyCaption(key: "PS-4", caption: "Sony PlayStation 4")]
PS4,
[KeyCaption(key: "XBOX-ONE", caption: "Microsoft Xbox One")]
XboxOne,
[KeyCaption(key: "SWITCH", caption: "Nintendo Switch")]
Switch
}
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public class KeyCaptionAttribute : AliasAttribute
{
public KeyCaptionAttribute(string key, string caption) : base(key, caption)
{
}
}
public static class GameConsoleUtility
{
public static string ToKey(this GameConsole enumValue)
{
return enumValue.ToAlias<GameConsole>(0);
}
public static GameConsole ToGameConsole(this string key)
{
return key.ToEnum<GameConsole>(0);
}
public static string ToCaption(this GameConsole enumValue)
{
return enumValue.ToAlias<GameConsole>(1);
}
public static List<GameConsole> GetAll()
{
return AttributeUtility.GetAll<GameConsole>();
}
}
代码量大大减少,因此整洁。与前两种尝试相比,它不仅更具可读性,而且更易于维护代码。
我希望它能帮助您使代码整洁,并提高可读性和可维护性的质量。
备注
对于要在 UI 上显示的标题或类似内容,会涉及到另一个问题,即本地化。我认为这是另一个问题。本文主要关注与枚举相关的关联数据。例如,即使在全局化的程序或系统中,您也可以有一个或多个关联键,如标题键、工具提示消息键、子选项引用。
如果您需要使用此实用工具类的本地化功能,Sacha Barber 的文章可以为您提供思路。
如何使用附加文件
附加文件 EnumFieldAttributeUtilities.zip 包含一个 VS 解决方案。该解决方案具有名为 Attempt1、Attempt2 和 Solution 的生成配置,因此请在运行测试代码之前在 VS 中切换生成配置。
VS 解决方案是在 VS Community 2017 for Mac 上创建的。如果您在使用 Windows 时遇到问题,请留言。
历史
- 2018/06/26:发布第一版。
- 2018/06/29:附加了包含本文代码的 VS 解决方案的 zip 文件。还添加了如何使用的部分。
- 2018/06/29:在备注部分添加了指向Sacha Barber 的文章的链接。
- 2018/07/12:根据评论中的讨论,进一步阐明了此解决方案的背景。