多样式星期名字转换 [Design, C#]
Written by Allen Lee
1. 原来的问题...
Johnsuna 在我的《关于枚举的种种 [C#, IL, BCL]》那里提出了这样一个问题:
现在我想做一个多版本的带农历的中国万年历,月历中有星期日、星期一至六,我想使用"星期一","一"或"Monday", "Mon",或"M",但也可能使用其组合,如“星期一Mon”, 于是我定义一个公共Style枚举,里面有ChineseFullName, ChineseShortName, EnglishFullName,EnglishShortName, EnglishSingleLetter等,然后又定义一个公共的ChineseFullName枚举,里面有:星期一,星期二等等,类似的EnglishShortName:Mon,Tue等(类推)。
因为可能要进行组合,比如Style为:ChineseFullName|EnglishShortName以便得到“星期一MON”的结果,所以考虑使用枚举而未使用数组。
我的问题是:
- 如果现在选择的是星期五,Style样式为EnglishShortName,如何取得“Fri”的字符串?如果样式Style选择的是EnglishShortName | ChineseShortName,又如何得到“Fri一”呢?
- 如果EnglishSingleLetter枚举,则其枚举值为:M,T,W,T,F,S,S,很明显,T(Tuesday)与T(Thursday)重复,S(Saturday)与S(Sunday)重复,这在枚举中是不允许的。那么,如何才能正确实现呢?
2. 正向方案:实施授权。
根据 Johnsuna 的需求,我们得首先有一个 DayStyle 枚举来表示各种可用样式的名字,由于需求明确提出要进行样式的复合表示,所以我们必须在枚举上加上 FlagsAttribute:
[Flags]
enum DayStyle
{
ChineseFullName = 0x0001,
ChineseShortName = 0x0002,
EnglishFullName = 0x0004,
EnglishShortName = 0x0008,
EnglishSingleLetter = 0x0010,
}
补充阅读:
如果你不知道我为什么这样为 DayStyle 的成员设值,你可以看看我的《关于枚举的种种 [C#, IL, BCL]》,我在文章讲述了位枚举的值的设置以及相关注意事项。
下面,我将采用 TDD 的思维一步一步探讨这个问题的解决方案。首先,根据上面的需求,我认为 Johnsuna 会喜欢下面这种做法:
textBox1.Text = day.ToString(
DayStyle.ChineseFullName |
DayStyle.ChineseShortName |
DayStyle.EnglishFullName |
DayStyle.EnglishShortName |
DayStyle.EnglishSingleLetter
);
上面这种方法很明显需要我们在 ToString 里面分析客户端把一个什么样的 DayStyle 传递进来了,当然包括单独一个 DayStyle 成员以及通过“|”运算符得到的成员组合这两种情况。
我们知道,当某个位枚举变量和某一位枚举成员的按位与操作结果不为零时,该变量包含了该枚举成员。这样,下面的代码可以放到 ToString 里面用于进行与 ChineseFullName 相关的操作:
if ((style & DayStyle.ChineseFullName) != 0)
{
// Oh, you want the Chinese full name!
}
我将用同样的方法处理其他枚举成员,由于需求中提到希望的到类似“星期一Mon”这样的结果,所以我们不难想象到 ToString 里面将会涉及到字符串相加这一操作。执行这一操作的最佳人员当然就是 StringBuilder 了,于是我们有了下面的设想方案:
if ((style & DayStyle.ChineseFullName) != 0)
{
m_Content.Append("星期一");
}
if ((style & DayStyle.EnglishShortName) != 0)
{
m_Content.Append("Mon");
}
补充阅读:
关于字符串的常见操作以及与之相关的性能问题,我推荐你阅读我所翻译的 Performance considerations for strings in C#,该文比较了直接在 String 对象上执行这些操作和使用 StringBuilder 来执行这些操作在性能上的不同表现,并且从定量的角度给出了一个用于判断使用哪种方法来执行这些常见操作的参考标准。
然而,上面的代码仅能用于示意,它不能真正用到实际中,为什么呢?很明显,如果我们硬编码这些星期的表示,那么 ToString 至少还需要一个参数来判断客户端需要星期几的表示。这个方案除了加重了我们的劳动强度让我们有借口叫老板加工资外,它几乎没有其他好处了。所以,我们必须想办法把这个“星期几”的具体表示分离出来。如何做到呢?很明显,Template Method 模式是一个让我们的繁重工作得到解脱的办法。
现在,我们要做的就是声明一个抽象类 Day 以及一系列的抽象方法,例如 ToChineseFullName,让 Day 的派生类,例如 Monday,实现这一组抽象方法,而 Day.ToString 就通过这一组抽象方法把获取具体的表示的工作“授权”给那些派生类:
protected abstract string ToChineseFullName();
public string ToString(DayStyle style)
{
if ((style & DayStyle.ChineseFullName) != 0)
{
m_Content.Append(ToChineseFullName());
}
//
}
好吧,现在万事俱备,只欠子类了:
class Monday : Day
{
protected override string ToChineseFullName()
{
return "星期一";
}
//
}
好了,剩下的工作就是如法炮制其他派生类,虽然这些工作是劳动密集型的,但你仍得着手完成它。下面是 Monday 的实现:
值得注意的是,我在 Day 中重载了 Object.ToString 方法,因为我不希望在任何可能隐式调用 Object.ToString 的地方得到一个类型的名字。你可以把这个重载后的版本看作是默认的样式表示转换,套用任何一个你喜欢的转换策略。
3. 后来的问题...
上面那个方案可行,但就是不好,因为它隐藏了一个炸弹,一旦这个炸弹爆炸,我们不但没借口向老板提出加工资,还要受老板责怪,并且要自己加班收拾残局。你能猜到这个炸弹是什么吗?
让我们回顾一下 DayStyle 枚举,这个枚举现在表明我们将可以处理两种语言,并且每种语言至少有两种表达方式,你是否想到了什么呢?如果我们现在要加多一种语言呢?问题就在这里了,这个枚举的成员不够稳定。现在我们已经要处理中文和英文两种语言了,将来难免要处理更多的语言,因为你可能希望你的程序面向国际市场。更糟糕的是,我们并不知道我们将要添加的语种有多少种表达方式,至少现在的情况是英语比汉语多了一个“首字母”表达方式,那么有谁又能知道将要添加的语中会带有哪些稀奇古怪的特殊表达方式呢?
很明显,DayStyle 枚举的不稳定将会扩散到与之相关的每一个角落,而维护这样的代码可能会变成一场噩梦。
如果你读过 Alan Shalloway 和 James R. Trott 的 Design Patterns Explained,你应该会记得他们对封装的精辟阐述:
Find what is varying and encapsulate it.
接下来,我将会尝试把变化的根源封装起来...
4. 反向方案:封装变化。
如果你读过我关于枚举的文章,你会知道我主张把枚举看作一种分类手段,并且仅当分类的细节相对稳定的情况下才考虑使用枚举,否则你应该考虑别的出路。
那么,在我们要处理的问题域中,哪些因素容易改变,哪些因素又较为稳定呢?很明显,DayStyle 绝对不会被归入稳定因素的行列,于是剩下的就是 Day 了,那么 Day 稳定吗?当然稳定,因为一个星期有且仅有7天,并且每天的名字也是固定的。那么,把 Day 表示成枚举就合适了:
enum Day
{
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
下面,我也将采用 TDD 的思维来一步一步探讨该问题的解决方案。由于我不知道 Johnsuna 会否喜欢上这个方案,我只能假设有一个喜欢这个方案的用户 Foo 了。既然 Foo 喜欢这个方案,他将很有可能使用如下方式来处理转换:
NameConverter nc = new NameConverter();
nc.AddInnerNameConverters(
new ChineseFullNameConverter(),
new ChineseShortNameConverter(),
new EnglishFullNameConverter(),
new EnglishShortNameConverter(),
new EnglishSingleLetterConverter()
);
textBox1.Text = nc.ToString(Day,Saturday);
从上面的代码可以看出,NameConverter 就是整个转换工程的“总管事”,而那些 XXXConverter 就是负责具体转换工作的“员工”。
“总管事”在这里的工作只是询问客户需要哪些转换,然后安排相关“员工”来完成具体的工作。由于我们将来可能会扩展更多的 XXXConverter 以满足更多的需求,所以 NameConverter 不可能(预先)了解到所有的这些具体的转换细节。为了使得“总管事”能够更好的统一管理整个转换过程,我们需要实施“目标管理”,让“总管事”关注“员工”的工作成果而不是工作过程。
那么,如何实施“目标管理”呢?既然“总管事”关注结果,那么我们可以考虑制定一个“行为规范”,即接口约定:
interface INameConverter
{
string ToString(Day day);
}
然后让所有准备参与工作的“员工”“学习”这一“行为规范”,例如:
class EnglishFullNameConverter : INameConverter
{
string INameConverter.ToString(Day day)
{
return day.ToString();
}
}
好了,现在我们要改变关注点,接下来,我们来看看“总管事”是如何管理“员工”的。作为“总管事”,它的职责有如下三点:
- 获悉客户的需求,即询问客户需要哪些转换服务;
- 安排相关的“员工”来处理具体的转换工作;
- 把最后的工作成果交到客户的手里。
首先我们来看第一点,为了更好的管理客户的需求,我们得首先把这些需求以后种方式收集起来:
private List<INameConverter> m_NameConverters = new List<INameConverter>();
然后就是接收客户的需求了:
public void AddInnerNameConverter(INameConverter innerNameConverter)
{
if (m_NameConverters.Contains(innerNameConverter))
{
return;
}
m_NameConverters.Add(innerNameConverter);
}
在这里,我假设重复的需求不是客户预期的,但客户也是人,可能会忙中出错多次发出相同的需求,这样我们就必须判断客户当前所提出的需求是否已经存在了。在这里,我使用 List<T>.Contains 方法来判断客户所提出的新需求是否已经存在了,该方法使用的默认判等策略是,如果 T 实现了 IComparable<T> 或者 IComparable,就会使用对应的实现来进行判等,否则就改用 Object.Equals 方法进行判等。我们知道,Object.Equals 的判等逻辑是不符合我们的要求的,所以我们必须实现自己的判等策略。我的判等逻辑很简单,我假设 XXXConverter 的名字是唯一的,那么每个 XXXConverter 将共享相同的实现。但这些相同的代码将放到哪里呢?很明显你不能放在接口中,所以我们需要改变“行为规范”的实现方式了:
abstract class NameConverterBase : IComparable
{
public abstract string ToString(Day day);
IComparable Members
}
值得注意的是,无论你选择实现 IComparable<T> 还是 IComparable,我所制定的简单判等逻辑的实现代码都是一样的,并且我认为这里根本没有使用泛型的必要,除非你打算重新制定更复杂的判等逻辑。另外,从这里你可以看到使用接口和使用抽象基类的区别。
如果说“总管事”的第一个职责是收集客户的需求,那么第二、三个职责就是处理客户的需求。我把处理客户的需求的代码合并到一个 ToString 中:
public string ToString(Day day)
{
foreach (NameConverterBase nameConverter in m_NameConverters)
{
m_Content.AppendFormat("{0} ", nameConverter.ToString(day));
}
return m_Content.ToString().TrimEnd();
}
当然,在使用这段代码之前,你得先把原来与 INameConverter 相关的代码重构为与 NameConverterBase 相关的代码。另外,NameConverter 内部也使用了 StringBuilder 进行字符串的连接处理。
为客户提供更便捷的服务是其中一条赢得客户的准则,“总管事”当然也深谙这点,所以它为客户提供了更方便灵活的收集需求接口:
public void AddInnerNameConverters(IEnumerable<NameConverterBase> innerNameConverters)
{
foreach (NameConverterBase innerNameConverter in innerNameConverters)
{
AddInnerNameConverter(innerNameConverter);
}
}
public void AddInnerNameConverters(params NameConverterBase[] innerNameConverters)
{
foreach (NameConverterBase innerNameConverter in innerNameConverters)
{
AddInnerNameConverter(innerNameConverter);
}
}
Code #08 就使用了第二个 AddInnerNameConverters 方法,这种可变参数方式为客户提供了极大的便利性。另外,如果客户的需求是已经用一个集合类收集好了,并且该集合类实现了 IEnumerable<T> 接口,那么“总管事”将使用第一个 AddInnerNameConverters 方法来接收客户的需求。值得注意的是,我没有直接在第一个 AddInnerNameConverters 中使用 List<T>.AddRange 方法添加需求,是因为我需要在添加需求之前进行一些额外的检查。
另外,这个方案的灵活性还在于,客户可以让自己的“员工”参与到转换工作中去,从而使得这个方案更具个性化和更有弹性,但条件是客户的“员工”必须学习并遵守我所订立的“行为规范”。
最后,可能有人会留意到,NameConverter 其实也可以看作是一个 NameConverterBase,为什么我没有让它们之间发生关系呢?试想一下,如果我们让这种关系存在,那么客户将可以进行下面这种操作:
NameConverter nc = new NameConverter();
nc.AddInnerNameConverter(new NameConverter());
textBox1.Text = nc.ToString(Day.Saturday);
很明显,这样的做法是不符合逻辑,即 NameConverter 的实例不应该持有另一个 NameConverter 的实例作为自己的“部属”,除非后者持有至少一个能进行具体转换工作的“员工”。让 NameConverter 和 NameConverterBase 发生继承关系将带来不必要的复杂性,虽然它也同时带来了多层管理等新特性。我认为这里没有必要让“总管事”同时管理“员工”和“管事”,而“管事”又管理着一批“员工”,即所谓的多层管理。
下面是这个解决方案的完整代码:
5. 我在干什么?
一开始,我顺着 Johnsuna 的思路探索第一个方案,但很快我就发现该方案的不稳定性。于是我重新考虑问题,并界定稳定因素和变化因素,很快我就发现 Johnsuna 所提出的思路可以逆转,并且新的方案既稳定又灵活。而这两个方案的命名则主要为了体现这一思路的逆转。
由此我想到平时很多问题解决不了或者解决得不好,很大程度就是因为我们的思路出了问题。如果下次你也在解决问题时发现某些不妥,不妨试着把思路逆转一下,看看能否发现新大陆。