但愿你也在这里…只一次






4.89/5 (68投票s)
基于上下文敏感条件的技巧描述
引言
不要认为你的既视感会让你误以为是软件重用!
V. Pupkin,工程师
目录
1. 动机
想想你多少次想写这样的东西
if (First Time Here)
ThisWillHappenButOnlyOnce();
唯一的问题是,该条件对于“if
”块的每个谓词调用都应单独起作用;理想情况下,它必须对代码行的位置敏感。
我可能会提前同意那些说这种用法是某种设计缺陷的观点。不过有一个“但是”:这不总是你的错;很多时候你处理的是别人的代码。通常,你必须提供一个事件处理程序,但是事件发生的时间点和事件的条件通常不在你的控制范围之内。
我大多在使用UI库时遇到类似情况。例如,如果你真的非常关注控件的焦点、Z轴顺序等,并且如果窗口的初始化更复杂,你知道某些调整只有在窗口在应用程序生命周期中第一次显示在屏幕上、被激活或获得键盘焦点时才可能。实际上,处理一个具体的事件处理程序会是这样:
if (!AlreadyActivated) {
AlreadyActivated = true;
ThisWillHappenButOnlyOnce();
} //if
//...
bool AlreadyActivated;
这就像这个例子中所示的那么简单;然而,这是一个常见且令人熟悉的烦恼。首先,代码不是局部的:每次你需要一次性行为时,都需要将布尔值(或位图枚举)槽添加到类实例变量集中——仅仅是为了在与下面示例中几乎相同的代码中使用它。其次,你不应该在代码的三个地方混淆谓词和布尔值的符号。最后,当你决定将行为从一个事件处理程序移动到另一个事件处理程序时,你最好重命名变量。这足以引起令人不快的既视感,即使这种重复枯燥的工作实际上已经完成。
2. 解决方案:堆栈跟踪来帮忙
通用解决方案基于System.Diagnostics.StackTrace
。首先,使用方法如下:
if (FirstTime.Here)
ThisWillHappenButOnlyOnce();
每当读取static
属性“FirstTime.Here
”时,如果在应用程序生命周期中第一次调用,它会返回“true
”,否则返回“false
”。这适用于每个不同的代码位置:放置在代码中其他任何位置的相同代码将再次返回“true
”,但只返回一次——即使该位置在相同的方法体内。它也适用于任何构建配置;它不依赖于调试信息的可用性。
下面的FirstTime
代码解释了如何实现这一点
public static class FirstTime {
public static bool Here {
get { return GetCodeLocationData(CodeLocationDictionary, Lock); } }
static bool GetCodeLocationData(
CodeLocationDictionary dictionary,
ReaderWriterLockSlim dictionaryLock) {
StackTrace stackTrace = new StackTrace();
int count = stackTrace.FrameCount;
for (int level = 0; level < count; level++) {
StackFrame frame = stackTrace.GetFrame(level);
MethodBase method = frame.GetMethod();
Type declaringType = method.DeclaringType;
if (ThisType == null) //lazy
ThisType = declaringType;
if (declaringType == ThisType) continue;
CodeLocationKey key = new CodeLocationKey(
method.MethodHandle.Value,
frame.GetNativeOffset());
dictionaryLock.EnterUpgradeableReadLock();
try {
bool alreadyVisited = dictionary.ContainsKey(key);
if (!alreadyVisited) {
dictionaryLock.EnterWriteLock();
try {
CodeLocationDictionary.Add(key, 0);
} finally {
dictionaryLock.ExitWriteLock();
} //try write lock
} //if
return !alreadyVisited;
} finally {
dictionaryLock.ExitUpgradeableReadLock();
} //try upgradeable read lock
} //loop
Debug.Assert("FirstTime.Here method should
always find stack frame of the caller" == null);
return false; //ha-ha! will never get here anyway
} //GetCodeLocationData
static Type ThisType = null; //obtained from lazy evaluation;
//simple typeof(FirstTime) would work but not used
//to make it all rename-safe and obfuscation-safe
static CodeLocationDictionary CodeLocationDictionary = new CodeLocationDictionary();
static ReaderWriterLockSlim Lock =
new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
} //class FirstTime
由于此代码片段依赖于其他一些声明,我将解释它们的含义(详情请参阅完整源代码)。
首先,我们需要唯一标识调用“Here
”方法的代码位置。查看堆栈帧数据显示,此标识可以基于调用方法的句柄和其嵌套方法体内方法调用的“本机偏移量”的组合。其次,这两个值在结构“CodeLocationKey
”中组合。由于此结构用作字典键,其标识规则使用基于重写的“object.Equals
”和“object.GetHashCode
”方法的常用技术相应地构建。这样,如果两个方法的句柄和偏移量都相等,则两个“CodeLocationKey
”实例被认为是相等的。static
字典“CodeLocationDictionary
”定义为“System.Collections.Generic.Dictionary<CodeLocationKey>
”。为什么字典值使用64位无符号整数,而简单的布尔值就可以做到?我将在下一节解释这一点。
为了线程安全,ReaderWriterLockSlim
以可升级模式使用。这样做有特殊原因:调用“Here
”的代码的相同位置在每次调用时都会导致对字典的读取访问,但只有在第一次时才需要写入访问。这样,当多个线程以可升级读取模式获取锁且没有线程以写入模式获取锁时,它们大部分时间都可以对字典进行只读访问。
循环中的代码还展示了如何根据“FirstTime
”类的类型,找到用于识别调用方法的正确堆栈帧。请注意,类型的计算不依赖于编码的类型名称,也不会在应用程序生命周期内重复;相反,它是基于惰性评估进行优化的。
3. 如何处理实例?
上述解决方案对于静态代码或在应用程序中独一无二的对象实例(例如主应用程序窗体或任何类型的单例)中使用时,效果相当好。如果每个对象实例都需要一次性行为,无论是代表众多子窗口、控件、菜单项之类的实例,该怎么办呢?
我最初的解决方案是一个独立的static
类,它会计算不同类型的字典键,并附带一个表示用户实例的额外字段。在阅读本文的第一个版本后,Paulo Zemek指出这会阻止实例被垃圾回收器回收。感谢他的提醒,我立即意识到这可能会导致内存泄漏。例如,如果应用程序创建了静态未知数量的实例,比如一个表示文本文档的控件,每次删除一个文档并创建一个新文档时,旧文档实例分配的内存将不会被回收,因为它被一个static
字典持有,该字典的生命周期与整个进程的生命周期大致相同。这样,重复删除和创建新文档将占用越来越多的内存。尽管这个问题可以解决,但将额外数据保存在字典键中的整个想法并没有提供足够的灵活性。用户可能还希望按线程、按线程和实例的组合或其他任何方式使用“FirstTime.Here
”谓词。
相反,所有对这一功能的需求都可以通过拥有一个非静态版本的“FirstTime
”类来满足。为此,我在“FirstTime
”中添加了一个新的非静态嵌套类“FirstTime.Instance
”;这个新类必须嵌套在“FirstTime
”中,以便在两者之间实现代码重用。
public static class FirstTime {
public class Instance {
public bool Here {
get { return GetCodeLocationData(CodeLocationDictionary, Lock); } }
CodeLocationDictionary CodeLocationDictionary = new CodeLocationDictionary();
ReaderWriterLockSlim Lock =
new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
} //class Instance
//...
} //class FirstTime
要使用这个类,用户应该根据需要获取并拥有尽可能多的“FirstTime.Instance
”类的独立实例
if (FirstTime.Here)
ThisWillHappenButOnlyOnce();
//...
// This works for Diagnostics.FirstTime only:
ulong activationNumber = FirstTime.Here;
//...
FirstTime.Instance FirstTime = new FirstTime.Instance();
对于“FirstTime.Instance
”类的每个实例,对属性“Here
”的调用计数将分别执行,使用不同的字典实例。在多线程访问字典的情况下,两个或更多线程只有在访问同一字典实例时才会相互锁定。
4. 如果你想知道更多怎么办?
我把上面的解决方案做得尽可能简洁。然而,在字典键中存储64位无符号整数存在一些冗余。这个值表示对“Here
”属性的调用次数。我假设这些信息可能有助于调试和诊断目的。
我没有通过一个通用类来暴露调用次数,而是创建了一个单独的类,它具有相同的名称和兼容的接口,但位于不同的命名空间下。原始的“FirstTime
”位于命名空间“SA.Univeral.Utilities
”下,而其诊断版本位于命名空间“SA.Univeral.Utilities.Diagnostics
”下,因此可以在调试完成后切换到更经济的非诊断版本,从而挤出额外的性能。
诊断版本如何才能拥有兼容的接口,同时又能暴露额外的调用次数信息?诀窍在于“Here
”的类型是不同的,但它们是单向赋值兼容的:“SA.Univeral.Utilities.Diagnostics.FirstTime.Here
”可以在使用“SA.Univeral.Utilities.FirstTime.Here
”的任何地方使用,但反之则不然。
“Here
”的两种返回类型之间的赋值兼容性基于隐式运算符声明。对于诊断版本,使用类型“CodeLocationData
”而不是布尔值。
public struct CodeLocationData {
internal CodeLocationData(Cardinal visitNumber)
{ this.visitNumber = visitNumber; }
public static implicit operator bool(CodeLocationData data)
{ return data.visitNumber < 1; } //true if this is a first visit
public static implicit operator Cardinal(CodeLocationData data)
{ return data.visitNumber; } //visit number
Cardinal visitNumber;
} //struct CodeLocationData
除此之外,FirstTime
的基本版本和诊断版本之间的差异微不足道;请在完整的源代码中查找更多详细信息。
我使用64位无符号值来计算调用次数的动机看起来足够有趣,可以在这里解释一下。
想象一下,你所有的代码都只忙于调用“FirstTime.Here
”,并且假设你拥有如此惊人的CPU速度,每个完整周期只耗时1微秒。如果你使用32位无符号整数作为你的调用计数器,它将在仅仅1.19小时内溢出。对于64位,这将花费将近……58.4万年。
我真的希望,即使你的代码寿命足够长,计算机速度变得更快,你也很难遇到比你开发生涯的五百万年还要近的截止日期。这个时间应该足够将你的代码移植到,比如说,128位。
5. 解决方案成本
在我的系统上,调用“FirstTime.Here
”大约需要40微秒,其中线程锁定贡献的时间不到2%。这足够快吗?
如果该技术被正确地用于UI目的,那么鼠标单击或键盘事件可能只发生一次或几次对“Here
”的调用。在这种情况下,即使一毫秒或几毫秒也无关紧要。
至于内存消耗,每个应用程序域最多可以有两个字典(如果同时使用了“FirstTime
”类的常规版本和诊断版本),并且键的数量不能超过调用“Here
”或“VisitNumber
”的代码行总数;每个键占用16字节。对于非静态类“FirstTime.Instance
”,每个实例将有一个单独的字典。
6. 构建代码和示例
该代码旨在支持Microsoft.NET v.3.5及更高版本。
我开发了两个UI示例,一个用于Windows Forms,另一个用于WPF,面向Microsoft.NET v.3.5。
原始代码是使用 Microsoft Visual Studio 2008 创建的,目标是 Microsoft.NET 3.5 和 4.6。可以使用“FirstTime.2008.sln”或“FirstTime.2015.sln”这两个不同的解决方案,使用相应版本的 Visual Studio 构建代码。
实际上,构建代码不需要 Visual Studio,因为可以通过运行批处理文件“build.2008.bat”或“build.2015.bat”来批处理构建代码。如果需要支持其他版本的平台,则需要编辑批处理文件。另一个选项是使用SharpDevelop。
7. 结论
感谢您的关注。我希望您觉得这些技术有用或足够有趣;如果没有,我希望您玩得开心:消除枯燥的工作是我的主要目标。
我很乐意收到您的反馈。
8. 鸣谢
Paul B. 慷慨地提供了他的性能测量结果。
根据我代码的早期版本,Paulo Zemek 指出了垃圾回收问题(见上文)以及线程安全方面的潜在问题。他的笔记帮助我重新思考了与线程和每个实例行为相关的部分实现。
Roberto Guerzoni 发现了一个关键的 bug,我已在2014年9月5日的v.2.1版本中修复。那是一个FirstTime
非诊断代码中的回归 bug;在实现按实例“首次”行为时,我忘记替换一个对字典的引用,这导致了字典键异常。Roberto 提供了确切的出错代码片段,其中 bug 用肉眼清晰可见。非常感谢你,Roberto!
9. 历史
- 2009年10月23日:初始版本
- 2009年10月24日:文章更新
- 2009年10月29日:版本1.1
- 2014年9月5日:版本1.2
- 2017年3月10日:版本1.3:项目结构清理,取消对.NET v.2.0的支持