C# 技巧集






4.96/5 (86投票s)
深入了解 C# 中一些不太为人所知但有时非常方便的强大功能。
目录
引言
C# 无疑是最有趣的编程语言之一。它结合了 C++ 的简洁语法、高效的虚拟机和强大的运行时环境。从生产力角度来看,该语言的托管方面非常有趣,但与本机 API 的通信也是间接可能的。此外,C# 允许在特殊上下文中 O使用指针。
这是我一直想写的文章类型。在本文中,我将尝试介绍一些我认为 C# 中特别有趣的内容。其中大多数技巧将基于 C# 中一些非常知名的特性,如属性或扩展方法。对于一种具有静态类型系统的强类型语言来说,C# 的表达能力可能是无与伦比的。
在编写了多年的 C# 应用程序后,我们还能学到什么?可能不多。但那些可能还没见过、但却寥寥无几的技巧,可能会改变我们解决特定问题的方式。我知道有些人几乎知道所有这些技巧,但如果只有一个以前未知的技巧,那么这篇文章就已经很有价值了。
背景
C# 中最有趣的东西实际上是由编译器(而不是语言)驱动的。让我们来看其中两个例子:async
和扩展方法。两者都是语言特性。对于 async
,编译器使用一些 .NET 框架类(更通用的如 Task
,以及更专门的如 AsyncTaskMethodBuilder
)来构造代码,这些代码利用 TPL,以便我们作为程序员能够最大程度地舒适地编写并发代码。扩展方法则稍微简单一些,在这里编译器只是将 a.b()
这样的代码结构替换为 X.b(a)
,其中 X
是公开扩展方法的 static
类。
编译器的一个特性是能够读取指令的行号、调用成员的名称等。这些特性在某些情况下非常有用。其实现基于编译器识别的特殊属性。这意味着属性和编译器之间存在可能的相互作用。因此,属性的作用远不止是可以通过反射读取的注解。
这是 C# 变得非常强大并带来大量可能编译器增强的部分。也值得在此指出,随着新的 C# 编译器,称为 Roslyn
(这是华盛顿州美国的一个城市的名字),属性将变得更加强大。其原因在于,以前可以编写一种 C# 预处理器,它会使用特定的属性进行代码替换。这可以给我们带来一些增强,例如“自动生成”的视图模型。以前的理念是,属性后面的行为会像一个指示器,即使没有在预处理器中使用,它仍然带有语义。因此,如果没有预处理器,也不会有任何痛苦。尽管如此,预处理器仍然有一些工作要做。完成之后,真正的 C# 编译器就可以处理创建的源代码。有了 Roslyn,这可以是一个过程。
但是,促使我写这篇文章的并非 C# 新编译器或 C# 新版本(v6)的想法,而是关于相当有用的属性或其他特性的信息大多是零散的。本文试图将其中一些收集起来——当然也欢迎补充!所以,如果你有对他人有益的技巧,请随时发表评论或给我发消息。
一系列技巧
我在本节中收集了一个技巧列表。每个技巧都介绍了一个特定的问题和一个常见的解决方案。然后,将基于某些 C# / 编译器特性的改进解决方案呈现出来,并附上(如果不是显而易见的话)为什么该解决方案更好的论据。
总的来说,本文不是关于提高 C# 性能的,然而,在某些部分,我可能对性能的关注程度会比其他部分高。如有必要,我会提出有关性能问题或潜在性能提升的背景信息。
联合 (Unions)
让我们设想我们正在编写一个将执行某些平台无关图形处理的应用程序。例如,Oxyplot
,这是一个绘图库。Oxyplot
库可用于 Windows Forms、WPF、Silverlight……不一而足。如果想对图形进行一些操作,就必须考虑颜色。颜色让生活更加多彩。当然,我们知道 Windows Forms 的 Color
结构或 WPF 的颜色结构,但是,由于我们是平台无关的,因此我们必须引入自己的类型。
我们要创建的颜色类型应该与 Windows Forms 的非常相似。因此,我们有
- 一个用于 alpha 的字段(字节 0-255)
- 一个用于 red 的字段(字节 0-255)
- 一个用于 blue 的字段(字节 0-255)
- 一个用于 green 的字段(字节 0-255)
这意味着我们自己的颜色结构长度为 4 字节,或者说是一个整数的大小。此外,这种颜色应该是可哈希的,这非常方便,因为哈希也由一个整数表示。如果我们想比较两个颜色是否相等,我们可以使用唯一的哈希,或者比较每个字段。如果计算颜色结构哈希的成本高于 2 次比较,我们应该始终比较字段来检测相等性。
public struct XColor
{
byte red;
byte green;
byte blue;
byte alpha;
public XColor(byte red, byte green, byte blue, byte alpha)
{
this.red = red;
this.green = green;
this.blue = blue;
this.alpha = alpha;
}
public byte Red
{
get { return red; }
}
public byte Green
{
get { return green; }
}
public byte Blue
{
get { return blue; }
}
public byte Alpha
{
get { return alpha; }
}
public int GetHashCode()
{
//Performs automatic computation of the hashcode
return base.GetHashCode();
}
}
问题:在这种情况下,如何提高我们自己的颜色类型在哈希映射中的性能?如果我们不重写 GetHashCode
方法的默认行为,那么对于结构体,哈希码将从实例的位模式计算得出。我们可以通过预缓存哈希码来提高这一点。此更改有两个重要后果
- 结构的大小将翻倍到 8 字节,因为哈希码的大小是 4 字节。
- 所有可能的修改都必须触发对缓存哈希码的重新计算。
这一切都非常不方便和烦人。最后,人们可能想从另一个角度解决这个问题。颜色变化相当少见,因此人们可以将颜色直接存储为整数。这样,我们就已经有了哈希码的缓存版本(无需进一步计算),大小减少到原始的 4 字节,并且修改无需触发重新计算。但是,设置颜色(如红色)会更复杂。
解决方案:如果我们考虑 C/C++ 等语言,我们可以控制位布局。在 C# 中,编译器 / MSIL 将确定布局。我们只关心里面有什么,而不是它是如何布局的。但是,在与本机 API 通信时,固定的顺序非常重要,这就是为什么引入了一个属性来设置结构的布局。
StructLayout
属性将通知 C# 编译器布局修改已启用,因此可以由程序员进行。然后,各个字段可以用 FieldOffset
属性进行修饰。此属性指定相对于结构起始位置的字节偏移量。因此,偏移量为 0
将导致该字段直接从内存中的结构开始处读取。
让我们在示例中使用这些属性
[StructLayout(LayoutKind.Explicit, Pack = 1)]
public struct XColor
{
[FieldOffset(0)]
byte red;
[FieldOffset(1)]
byte green;
[FieldOffset(2)]
byte blue;
[FieldOffset(3)]
byte alpha;
[FieldOffset(0)]
int value;
public XColor(byte red, byte green, byte blue, byte alpha)
{
//The following line is important in this case
this.value = 0;
this.red = red;
this.green = green;
this.blue = blue;
this.alpha = alpha;
}
/* The rest */
public int GetHashCode()
{
//Just returns the value - the best and fastest hashcode
return value;
}
}
请注意构造函数的更改。我们需要明确告诉编译器我们实际上设置了所有字段,即使值字段只是所有其他字段的组合表示。感谢 Paulo 指出这一点!
这是可能的最具性能的方式。结构体只有 4 字节长,但是其子部分被视为不同。如果我们与 value
字段通信,我们将与整个结构体通信。如果我们访问 green
字段,我们只使用第 2 字节的子集。这里不需要位运算符之类的东西,一切都只是在内部使用相同数据的不同表示,这非常快。
这种技术在哪里有用?有多种应用,例如,拥有非常快速的简化的索引转换(2D 到 1D 反之亦然)。过去,我主要使用此模式来提高性能或减小大小开销。
这种模式何时不起作用?嗯,不幸的是,它不适用于托管对象,即任何 .NET 类或委托的实例。首先,我们只能在结构体上声明这些布局选项。其次,我们也只能使用本身就是结构体的字段。CLR 类将面临比它们看起来更重的麻烦,因为它们有一个小的前缀,存储指向类型信息的指针。这使得诸如 is
、功能强大的动态类型转换(增强了 RTTI(运行时类型信息))等功能成为可能,但在这种情况下却是一种缺点。
→ 示例代码 unions.linq
密封 (Sealing)
密封很有用,如果你想停止继承链。大多数人都知道可以在类上使用 sealed
关键字。这是一个非常明显的用例,因为类是唯一参与继承的抽象对象。接口始终可以实现,结构、枚举等永远不能继承。
但是,sealed
关键字也可以应用于方法。这不像密封整个类那样具有强大的效果,但在某些时候可能有用。在我们讨论这是否真的有必要以及我们能获得什么好处之前,我们必须先讨论密封的一般概念。
总的来说,有三种 C# 程序员
- 不知道(或不在乎)密封的人
- 强烈反对密封的人
- 强烈支持密封的人
总的来说,我认为自己属于第三类。但是,值得在此指出,将 sealed
应用于类可能会弊大于利,所以在这里采取防御性策略可能比盲目地密封一切要好。我个人将此关键字用于不打算继承的类,并且仅在库级别使用(这样如果以后需要更改,则仅影响内部库)。
因此,可以说密封是一种为其他程序员提供如何使用(或扩展)我们系统的方向。
然而,虽然第一类人不需要进一步讨论,但仔细研究第二类人的论点绝对是有趣的。基本上,这些论点可以总结为以下陈述
- 性能优势(如果有)可以忽略不计
- 它打破了扩展现有类型的想法
- 面向对象编程基于多态性,而继承是其一个重要方面
我们稍后将讨论对性能的潜在影响。现在,后两个论点应更仔细地检查。这两个论点基于继承是关键且总是合理的假设。然而,这肯定不是真的。例如,JavaScript 是一种面向对象的语言,没有类。在 JavaScript 中,对象使用原型模式创建对象,任何现有对象都可以是另一个对象的“父”对象,这或多或少地像基类的实例。
就扩展现有类而言,密封不是问题的核心。在我看来,人们应该始终仅依赖接口,对我而言,接口就像编译器契约。接口告诉编译器“我不知道确切的类型,但我知道会有这种功能”。一个完美的接口将产生零开销,因为从编译器的角度来看,它只是关于定义。在这种情况下,TypeScript 做得很棒,其中每个定义都基于一个接口,该接口在运行时完全透明(因为只有编译器在检查源代码时使用了它——编译器生成的 JavaScript 代码中完全没有接口定义)。
因此,我强烈建议考虑对所有依赖项都使用接口。如果一个人确实依赖于特定类型的对象,那么就可以考虑密封该对象的底层类是否是一个好主意。
回到性能论点。通常的说法是,密封类可以提高性能。类似地,如果密封整个类过于激进,密封单个方法也可能带来性能优势。本质上,这种好处与运行时不必担心虚拟函数表的扩展有关。密封类不能被扩展,因此,它不能比其基类表现出更多的多态性。在现实世界中,我没有见过任何一个案例,密封一个完整的类(或一个类的许多方法)导致了可检测到的性能提升。
总而言之,我认为我们永远不应该仅仅出于性能原因而密封一个类。密封的正确论点应该是为其他程序员提供一些提示,哪些应该用作扩展的基础,哪些不应该(因为不能)。总的来说,我们应该默认在标记为 internal
的类上使用 sealed
,并避免默认使用 sealed
作为 public
类。
让我们看一个简短的示例
class GrandFather
{
public virtual void Foo()
{
"Howdy!".Dump();
}
}
class Father : GrandFather
{
public sealed override void Foo()
{
"Hi!".Dump();
}
}
sealed class Son : Father
{
}
现在的问题是:我们能否期望编译使用 call
而不是 callvirt
?答案显然是否定的……
IL_0000: newobj UserQuery+GrandFather..ctor
IL_0005: callvirt UserQuery+GrandFather.Foo
IL_000A: newobj UserQuery+Father..ctor
IL_000F: callvirt UserQuery+GrandFather.Foo
IL_0014: newobj UserQuery+Son..ctor
IL_0019: callvirt UserQuery+GrandFather.Foo
这是在启用优化的情况下编译的。即使在 Son
的情况下,应该很清楚基类 Father
拥有 Foo()
方法的最终版本,但仍然使用了 callvirt
指令。
→ 示例代码 sealing.linq
只读和常量 (Readonly and const)
当然,每个 C# 程序员都知道这两个关键字,readonly
和 const
。两者都可以用于不变值。如果我们使用 static readonly
的组合,我们可以拥有一个行为与 const
声明的字段非常相似的变量。但是,让我们注意一些常量表达式的重要方面
const
需要一个原始类型(int
、double
…以及string
实例)const
可以用作switch
-case
语句中的情况const
表达式将导致编译时替换和求值
另一方面,任何 static
字段仅作为普通字段,具有独立于特定类实例的属性。这意味着 static readonly
字段只是一个普通的实例无关字段,其属性最初由构造函数设置,之后无法更改。
让我们看一个使用标准表达式中一些常量的简短示例代码。
const int A = 3;
const int B = 4;
void Main()
{
int result = A + B;
result.Dump();
}
我们的程序执行了多少次加法运算?答案是零!为什么?嗯,由于 A
和 B
都是编译时常量,优化器可以执行一个简单的优化,即将二进制加法运算替换为一个常量,该常量表示运算结果。
IL_0000: ldc.i4.7
IL_0001: stloc.0 // result
IL_0002: ldloc.0 // result
IL_0003: call LINQPad.Extensions.Dump
现在,我们通过使用 static readonly
对代码进行轻微修改。
static readonly int C = 3;
static readonly int D = 4;
void Main()
{
int result = C + D;
result.Dump();
}
这次的加法运算次数是多少?当然是一次!为什么?编译器不执行两个变量 C
和 D
的替换。因此,表达式中实际上没有常量,因此必须在运行时求值二进制运算。
IL_0000: ldsfld UserQuery.C
IL_0005: ldsfld UserQuery.D
IL_000A: add
IL_000B: stloc.0 // result
IL_000C: ldloc.0 // result
IL_000D: call LINQPad.Extensions.Dump
我们已经看到了何时应该使用常量,何时应该偏好 static readonly
字段。如果用例未知,则应始终偏好后者。如果我们要求在 switch
-case
语句中使用该字段(当然,如果值为原始类型),则需要 const
。否则,const
应该只用于真正符合该名称的常量:将来永远不会更改的字段。
一个很好的例子是 Math.PI
。另一方面,连接字符串等永远不应存储为常量。原因很简单:让我们考虑一个库,它使用我们的连接字符串常量字段。如果我们更改此主程序集,我们还必须重新编译所有基于它的其他程序集,因为常量是在编译时插入的。由于访问字段被推迟到运行时,这不会使用 static readonly
变量发生。
→ 示例代码 constread.linq
友元类 (Friend Classes)
C++ 引入了友元类的概念。友元类是另一个类,它可以访问当前类的私有成员。这个概念就像是说“我是一个类,我的私有字段是秘密,但是我可以在我的朋友之间分享这些秘密”。
显然,从架构师的角度来看,友元类是应该不存在的问题的解决方案。类应该主动执行信息隐藏。我们不关心实现,而关心通过使用类可以获得功能。如果另一个类不仅需要特定类作为输入,还需要特定类中的特定实现,那么我们不仅有强耦合,而且还有一种非常不健康的关系,既不健壮,也不太灵活。
尽管如此,有时两个类之间存在很强的关系。.NET 框架也包含此类类。一个例子?泛型 List<T>
类使用 IEnumerable<T>
接口的特殊实现作为迭代器的基础。不使用更通用的实现的原因是,这允许一个专业的实现,它利用了列表类型本身的内部实现。
如何让某个类访问另一个类的内部实现,而不成为其派生类?这个问题的答案是嵌套类。在列表的情况下,它是一个嵌套结构,遵循相同的原理。
让我们看一些示例代码
interface SomeGuy
{
int HowOldIsYourFriend();
}
interface OtherGuy
{
}
class MyGuy : OtherGuy
{
private int age;
public MyGuy()
{
age = new Random().Next(16, 32);
}
class MyFriend : SomeGuy
{
MyGuy guy;
public MyFriend(MyGuy guy)
{
this.guy = guy;
}
public int HowOldIsYourFriend()
{
return guy.age;
}
}
public SomeGuy CreateFriend()
{
return new MyFriend(this);
}
}
在示例中,我们要建立从 MyGuy
到 SomeGuy
的友元关系。当然,有可能返回类 MyGuy.MyFriend
,但那样我们就需要更改嵌套类的访问修饰符,并暴露一部分内部信息。在这种情况下,我们希望隐藏我们创建的作为朋友的类实际上是嵌套的。
这个示例需要给定的构造函数吗?不一定。由于嵌套类位于父类的作用域内,因此它始终可以访问父类实例的 private
成员。
让我们修改我们的示例
interface SomeDude
{
void WhatsHisAge(OtherDude dude);
}
interface OtherDude
{
}
class MyDude : OtherDude
{
private int age;
public MyDude()
{
age = r.Next(16, 32);
}
class MyFriend : SomeDude
{
public void WhatsHisAge(OtherDude dude)
{
var friend = dude as MyDude;
if (friend != null)
("My friend is " + friend.age + " years old.").Dump();
else
"I don't know this dude!".Dump();
}
}
public SomeDude CreateFriend()
{
return new MyFriend();
}
}
当然,在传递 MyDude
的情况下,我们不需要转换。尽管如此,通过这种方式,我们只耦合到接口,我们可以编写friend
类,但不必这样做。
我们已经讨论过一些 .NET 内部类使用这种技巧,通过依赖另一个类的内部实现来获得一些性能。我认为特别有趣的是,你可以将这种嵌套类方法与标记为 partial
的类结合起来。这样,你就可以编写一个代理,其中每个代理都位于自己的文件中。每个文件将包含两部分。一部分是代理实现,另一部分是包含嵌套类的部分类。通过这种组合,你可以以一种或多或少透明的方式真正模仿友元类。
但是,说到代理,我们可能会发现其他东西。.NET 框架已经有一个预先准备好的代理解决方案,它可以处理任意类,只要它实现了 MarshalByRefObject
。这非常巧妙,可能有一些非常酷的应用。例如,我们可以自动将所有与性能相关的类包装到这样的代理中,这将为我们提供自动调用测量。
System.Runtime.Remoting.Proxies
命名空间中的 .NET RealProxy
类允许我们为另一个类型创建透明代理。透明意味着它看起来与被代理的目标对象完全一样,对于其他对象。但实际上,它不是:它是你的类的一个实例,该类派生自 RealProxy
。RealProxy
的主要缺点是,被代理的类型必须是接口。另外,正如已经说过的,继承自 MarshalByRefObject
的类也可以胜任。
例如,这允许我们在客户端和对真实目标调用的任何方法之间应用一些拦截或中介。然后,我们可以将此与工厂模式等其他技术结合起来。组合技术可以将透明代理而不是真实对象返回。这使我们能够拦截对真实对象的所有调用,并在每次方法调用之前和之后执行其他代码。
让我们只使用位于 System.Runtime.Remoting.Proxies
命名空间中的类。在下一个示例中,我们创建一个基于现有对象的代理。但是,也可以使用代理来模拟接口对象。然而,这一次,我们只对包装对现有对象的调用感兴趣。
class MyProxy<T> : RealProxy
{
T target;
public MyProxy(T target)
:base(typeof(T))
{
this.target = target;
}
public override IMessage Invoke(IMessage msg)
{
var message = msg as IMethodCallMessage;
var methodName = (String)msg.Properties["__MethodName"];
var parameterTypes = (Type[])msg.Properties["__MethodSignature"];
var method = typeof(T).GetMethod(methodName, parameterTypes);
var parameters = (Object[])msg.Properties["__Args"];
var sw = Stopwatch.StartNew();
var response = method.Invoke(target, parameters);
sw.Stop();
("The invocation of " + methodName + " took " + sw.ElapsedMilliseconds + "ms.").Dump();
return new ReturnMessage(response, null, 0, null, message);
}
}
我们必须重写 Invoke
方法。此方法为我们提供一个 IMessage
实例(来自 System.Runtime.Remoting.Messaging
命名空间中的类),其中包含有关调用的所有信息。这样,我们就可以读出调用另一个方法所需的一切。在这种情况下,我们启动并停止一个新的 stopwatch
。之后,我们返回调用的结果,并转储 stopwatch
求值的结果。
创建和使用这个代理非常简单。我们可以编写以下辅助方法
T CreateProxy<T>(T target) where T : class
{
var proxy = new MyProxy<T>(target).GetTransparentProxy();
return proxy as T;
}
最后,我们可以这样使用它。假设我们有一个名为 MyClass
的类。让我们看看这是什么样子
class MyClass : MarshalByRefObject
{
public void LongRunningProc()
{
Thread.Sleep(1000);
}
public string ShortRunningFunc()
{
Thread.Sleep(10);
return "test";
}
}
我们如何包装这个并与代码交互?这非常简单,使用我们的辅助方法
var real = new MyClass();
var proxy = CreateProxy(real);
proxy.LongRunningProc();
proxy.ShortRunningFunc().Dump();
在不需要直接调用的场景中,代理可能会派上用场。如果这种情况非常特殊(仅限于一个类,具有有限的方法集),那么使用 RealProxy
可能有点过度。否则,使用这个类非常有益,并且可能恰到好处。
→ 示例代码 friend.linq
调试器属性 (Debugger Attributes)
我们可能对用于控制调试器或编译器的一系列有用属性感兴趣。
让我们简要看一看一系列非常有用的属性
//Ensures that the method is not inlined
[MethodImpl]
//Ensures that execution is only possible with the given symbol being defined
[Conditional("DEBUG")]
//Marks a method or class as being deprecated
[Obsolete]
//Marks a parameter as being the name of the file where the caller of the method is implemented
[CallerFilePath]
//Marks a parameter as being the integer line number in the source where the current method is called
[CallerLineNumber]
//Marks a parameter as being the name of the method that contains the call to the current method
[CallerMemberName]
//Sets the display of the class in the debugger
[DebuggerDisplay("FirstName={FirstName}, LastName={LastName}")]
//Instruments the debugger to skip stepping into the given method
[DebuggerStepThrough]
//Similar to stepthrough, hides the implementation completely from the debugger
[DebuggerHidden]
//Handy for unit testing: Lets internal types be visible for another library
[InternalsVisibleTo]
//Sets a static variable to be not shared between various threads
[ThreadStatic]
DebuggerHiddenAttribute
和 DebuggerStepThroughAttribute
类之间有什么区别?虽然两者都可以设置在方法上,但 [DebuggerHidden]
注解不能设置在类上。下一个,也是最重要的区别是,用 [DebuggerStepThrough]
属性注解的代码将在调用堆栈中显示为外部代码。这将至少让我们知道正在运行一些其他代码。
另一方面,使用 [DebuggerHidden]
标志,我们根本看不到正在运行代码的任何迹象。从调用堆栈的角度来看,就好像隐藏的代码不存在一样!
所以让我们看一些例子。让我们从非常有用的参数命名开始。我们可以编写一个像下面这样的方法
void PrintInfo([CallerFilePath] string path = null,
[CallerLineNumber] int num = 0, [CallerMemberName] string name = null)
{
path.Dump();
num.Dump();
name.Dump();
}
直接调用此方法而不为任何参数赋值,将导致这些值由编译器插入。它会自动插入正确的值,例如当前行号或调用 PrintInfo
方法的名称。
最后,我们可能只想在调试模式下打印调用者信息。我们可以通过注解方法来实现
[Conditional("DEBUG")]
void PrintInfo([CallerFilePath] string path = null, [CallerLineNumber] int num = 0,
[CallerMemberName] string name = null)
{
path.Dump();
num.Dump();
name.Dump();
}
当然,DEBUG
符号非常有用,因为它在 Visual Studio IDE 中默认在调试模式下被激活。然而,有时我们可能想引入自己的符号。让我们考虑以下
#define MYDEBUG
/* ... */
[Conditional("MYDEBUG")]
void PrintInfo([CallerFilePath] string path = null, [CallerLineNumber] int num = 0,
[CallerMemberName] string name = null)
{
path.Dump();
num.Dump();
name.Dump();
}
如果我们注释掉第一行,那么 PrintInfo()
将不会被调用。否则,所有调用都将被考虑并在运行时执行。
→ 示例代码 debugger.linq
别名 (Aliasing)
using
关键字是 C# 语言的一个非常棒的特性。它的优点是你不必像 C++ 那样写 using namespace
,而只需写 using
。此外,你还可以(!)将其用于自动释放可处置对象,即自动调用实现 IDisposable
的对象的 Dispose()
方法。
以下方法创建一个名为 foo.txt
的新文件。
using (File.Create(@"foo.txt")) ;
整个 using
块为我们做了很多工作。
IL_0005: ldstr "foo.txt"
IL_000A: call System.IO.File.Create
IL_000F: stloc.1 // CS$3$0000
IL_0010: leave.s IL_001C
IL_0012: ldloc.1 // CS$3$0000
IL_0013: brfalse.s IL_001B
IL_0015: ldloc.1 // CS$3$0000
IL_0016: callvirt System.IDisposable.Dispose
IL_001B: endfinally
所以我们免费获得了一个 try-finally
块,它总是在 finally
子句中使用 Dispose()
方法。因此,我们可以确定我们使用的资源的最终析构函数确实被调用了。
让我们回到 using
指令的原始含义。让我们考虑以下代码
using System.Drawing;
using System.Windows;
问题:如果我们想创建像 Point
这样的类型的实例,我们需要指定完整的命名空间路径。这是因为 Point
名称在 System.Drawing.Point
和 System.Windows.Point
之间存在歧义。虽然这种完整的规范是一种可能的解决方案,但它可能不是理想的解决方案。
解决方案:让我们考虑我们的代码只需要 System.Windows.Point
的一个地方来设置某个值,通过转换一个 System.Drawing.Point
。为什么我们要使用完整的命名空间?此外,我们可能还有其他名称冲突,通过省略第二个 using
语句也可以避免这些冲突。
完美的解决方案仍然会缩短 System.Windows
中 Point
类型的限定符。类似如下的内容可以完成工作
using System.Drawing;
using win = System.Windows;
每次我们引用 System.Windows
命名空间中的某个类型时,我们都必须添加 win.
前缀。但是,如果我们的问题只是使用另一个 Point
引起的单个歧义,我们也可以选择以下语句
using System.Drawing;
using WinPoint = System.Windows.Point;
这就是我们所说的类型别名,或者简称类型别名。这实际上非常类似于 C/C++ 中的 typedef
声明。如果我们对此进行一些实验,我们会注意到以下有趣的现象
using System.Drawing;
using var = System.Windows.Point;
这实际上是可能的!当然,这是一种非常邪恶的反模式,幸运的是,我从未在真实代码中见过它。但为什么这是可能的?显然,var
不是一个普通的关键字。事实上,var
已经是一个别名,但是是一种动态别名。编译器永久地重新定义了 var
的含义,以匹配赋值右侧的确切类型。我们可以通过为 var
指定一个非动态(固定)值来覆盖此行为。
这种别名对于泛型可能非常有用。让我们考虑以下代码
List<KeyValuePair<string, string>> mapping = new List<KeyValuePair<string, string>>();
mapping.Add(new KeyValuePair<string, string>("default", @"foo.txt"));
这相当长,看起来很丑。如果这只在一个文件中出现,别名似乎就派上用场了!所以让我们使用一个名为 StringPair
的别名来表示 KeyValuePair<string, string>
。
using StringPair = System.Collections.Generic.KeyValuePair<string, string>
// ...
List<StringPair> mapping = new List<StringPair>();
mapping.Add(new StringPair("default", @"foo.txt"));
别名应始终谨慎使用。它们可能令人困惑,并可能使问题更加复杂。但是,在某些情况下,它们可能是使代码更优雅、更易于阅读的正确解决方案。
→ 示例代码 aliasing.linq
操作接口和枚举 (Operational Interfaces and Enumerations)
接口是 C#(或 Java)的顶级特性之一。事实上,我一直推荐进行核心能力接口定义。我们还可以遵循经验法则:如果你不确定是采用 abstract
基类还是接口作为实现基础,接口是正确的选择。此外,对于依赖项等,如果有任何不确定性,接口是正确的选择。
总的来说,接口在我们的代码中提供了更大的灵活性。它们简化了测试,并且可以很好地结合。此外,由于 C# 具有扩展方法,我们实际上可以从代码阅读者的角度为它们提供一些实现。这实际上就是本节将要讨论的内容。在这里,值得注意的是,这只是阅读者的 POV,而不是程序的 POV。对于程序来说,接口仍然没有那个方法。相反,它是一个 static
类,提供了一个函数,该函数只接受一个实现该接口的类的实例作为其第一个参数。
为了看到这确实很方便,让我们考虑以下类图,它显示了规格模式的轮廓
当然,我们可能总是对实现相同的方法 And
、Or
和 Not
感兴趣。但是,即使我们将这三个方法外包到一个 static
类,并用类似下面的内容替换它们,我们仍然需要一直进行复制粘贴
public ISpecification And(ISpecification right)
{
return SpecificationImplementation.And(this, right);
}
public ISpecification Or(ISpecification right)
{
return SpecificationImplementation.Or(this, right);
}
public ISpecification Not()
{
return SpecificationImplementation.Not(this);
}
这非常无聊且容易出错,因为每次复制粘贴过程(即使这真的很简单,而且所有这些机器人工作可能都无误完成)。所以我们可以对描述的模式进行一些非常简单的修改。
基本上,我们将把这三个方法排除在接口之外。然后,我们通过实现三个扩展方法来重新包含它们。最后,我们得到一些如下所示的代码
static class Extensions
{
public static ISpecification And(this ISpecification left, ISpecification right)
{
return new AndSpecification(left, right);
}
public static ISpecification Or(this ISpecification left, ISpecification right)
{
return new OrSpecification(left, right);
}
public static ISpecification Not(this ISpecification value)
{
return new NotSpecification(value);
}
}
扩展方法的好处是,通过在不同的命名空间中重新实现相同的扩展方法并使用另一个命名空间,它们总是可交换的。此外,实际方法始终优先于扩展方法,这允许特定类添加与扩展方法同名的方法。应注意的是,这不会导致多态行为,因为编译器必须选择正确的方法。
这意味着这里绝对没有 vtable
和 call virt
的执行。另一方面,我们可以使用这种技术为 delegate
或 enum
类型等对象提供一些附加行为。让我们从一个简单的枚举开始。
enum Duration
{
Day,
Week,
Month,
Year
}
现在我们可以应用一个扩展方法来引入一些非常方便的东西。在这种情况下,我们可能只是增强枚举类型,使其能够与 DateTime
值一起使用。这使得枚举比以前更有用,并使它们看起来像具有功能的类实例。
static class Extensions
{
public static DateTime From(this Duration duration, DateTime dateTime)
{
switch (duration)
{
case Duration.Day:
return dateTime.AddDays(1);
case Duration.Week:
return dateTime.AddDays(7);
case Duration.Month:
return dateTime.AddMonths(1);
case Duration.Year:
return dateTime.AddYears(1);
default:
throw new ArgumentOutOfRangeException("duration");
}
}
}
同样的技巧可以应用于任何 delegate
。让我们只看一个小示例来说明这一点。
static class Extensions
{
public static Predicate<T> Inverse<T>(this Predicate<T> f)
{
return x => !f(x);
}
}
/* ... */
Predicate<int> isZero = x => x == 0;
Predicate<int> isNonZero = isZero.Inverse();
虽然这很有趣,但并非有很多应用程序会积极受益于这种编码风格。尽管如此,了解 C# 为我们提供了将这些委托用作一流面向对象元素的机会还是很好的。
→ 示例代码 operational.linq
未文档化的特性 (Undocumented Features)
C# 有一些未文档化的特性,可以在非常特殊的情况下使用。所有特殊构造都以双下划线开头,并且不被智能感知覆盖。在这些未文档化特性的一些可能性中,我想讨论以下两个方面
- 参考文献
- 参数
可以使用 __makeref
构造生成引用。此函数接受单个对象作为输入,并返回 TypedReference
类型的实例。然后可以使用此实例通过 __reftype
函数进行进一步检查。在这里,我们取回原始对象的底层 Type
。最后,还可以通过使用 __refvalue
函数返回存储引用的值。这可以通过使用 __refvalue
函数来实现。
TypedReference
类型通过包含内存位置的引用以及数据类型的运行时表示来表示数据。只有局部变量和参数可用于创建 TypedReference
类型的实例。这排除了使用字段来生成此类实例。
让我们看看前面介绍的函数在实际中的应用
int value = 21;
TypedReference tr = __makeref(value);
Type type = __reftype(tr);
int tmp = __refvalue(tr, int);
type.Dump();
tmp.Dump();
我们将要看的第二个方面是可变数量的参数列表。在 C# 中,我们通常使用 params
关键字来自动生成输入数组。但是,还有第二种可能性,即使用未文档化的 __arglist
函数。此函数接受不定数量的参数并将它们包装在一种特殊类型的变量中。目标函数随后可以采用 __arglist
类型的实例来生成一些附加代码。此代码可用于实例化新的 ArgIterator
。
让我们看看这可能如何使用
void Show(__arglist)
{
var ai = new ArgIterator(__arglist);
while(ai.GetRemainingCount() >0)
{
TypedReference tr = ai.GetNextArg();
TypedReference.ToObject(tr).Dump();
}
}
所以我们又回到了我们的 TypedReference
类型。这很有趣,因为这仅仅意味着可变参数列表使用引用来透明地从一个方法传递到另一个方法。GetNextArg
方法用于出队参数列表的头部。通过使用 GetRemainingCount
,我们可以访问参数列表中存储了多少剩余参数。
LINQpad 能够使用未文档化的特性编译所有查询。但是,LINQpad 会在编辑器中显示一些错误。这可能会令人困惑,但并不构成问题。
目前,我认为除非一个人使用未文档化的特性来解决一个非常特殊的问题,否则不应该触及它们。在这种情况下,人们应该考虑为使用的未文档化特性放置一个抽象,以便通过仅更改抽象的实现就可以将代码从使用未文档化特性转换为不使用它们。
这是必需的,因为不能保证这些特性在将来某个时候可用。
→ 示例代码 undocumented.linq
索引器 (Indexers)
索引器非常有用且相当有趣。它们的创建方式如下
class MyClass
{
public int this[int index]
{
get { return index; }
}
}
当然,我们不限于单个索引器。此外,我们还可以使用其他类型,如 string
。因此,以下也是可能的
class MyClass
{
public int this[int index]
{
get { return index; }
}
public string this[string key]
{
get { return key; }
}
}
现在我们可以尝试更进一步,看看是否也可以定义具有泛型参数的索引器,就像泛型方法一样。但是,我们只会意识到不幸的是这不可能。此外,像 ref
或 out
这样的关键字也不可能。尽管如此,这样的索引器因此非常类似于方法。事实上,像属性一样,索引器只是由编译器生成的方法。
由于索引器只是方法,我们可以使用 params
关键字。
class MyClass
{
public int this[params int[] indices]
{
get { return indices.Sum(); }
}
}
这一切都非常不错,在某些情况下可能非常有用。但是,索引器只是方法这一事实带来了一个缺点。编译器需要为索引器选择一个合适的名称。默认名称只是 Item
。由于索引器像属性一样,我们有一个可能的 get
和一个可能的 set
方法。因此,get
方法将被命名为 get_Item
,set
方法将被命名为 set_Item
。
现在不太可能我们给方法起这样的名字。首先,我们不应该使用小写字母开头,其次,我们也应该避免在方法名中使用下划线。但更糟糕的是,这会阻止我们拥有一个名为 Item
的属性。现在我们能做什么?幸运的是,C# 编译器知道一个属性,可以用来更改默认名称。
该属性称为 IndexerNameAttribute
,位于(像所有其他有用的属性一样)System.Runtime.CompilerServices
命名空间中。让我们看看这可能是什么样子
class MyClass
{
[IndexerName("MyIndexer")]
public int this[int index]
{
get { return index; }
}
public string Item
{
get { return "Test"; }
}
}
这个命名对我们的代码没有直接影响。如果我们使用像 get_Item
这样的方法名在反射中进行搜索,那么在这种情况下我们就找不到索引器。这只是另一个提示,我们应该始终仔细考虑使用反射和插入字符串来查找特定方法时的可能性。应该考虑其他标准,以便尽可能保持健壮。
重命名也有另一个后果:如果我们在同一个类中有更多的索引器,我们也需要将完全相同的属性放在它们上面。C# 不允许我们给不同的索引器命名。同一类中的每个索引器都需要具有相同的反射名称。
→ 示例代码 indexer.linq
避免装箱 (Avoid Boxing)
大多数性能问题都源于过多的分配。仔细研究给定的代码可能经常会暴露不必要的分配,尤其是装箱/拆箱。很多时候,这些装箱/拆箱操作不像下面的代码那样明显
var number = 4; //number stored as an integer - no boxing
object count = 3; //count is an object and 3 is an integer, this will be boxed
当我们查看生成的 MSIL 时,我们会看到装箱操作会带来一些开销
IL_0000: ldc.i4.3
IL_0001: box System.Int32
IL_0006: stloc.0 // count
将转换回一个名为 icount
的整数变量需要拆箱操作。很可能装箱操作至少有一个拆箱对应项。
这是 MSIL 中的样子
IL_0007: ldloc.0 // count
IL_0008: unbox.any System.Int32
IL_000D: stloc.1 // icount
但是,有时这并不那么明显。一个著名的例子是 string
连接。在这里,我们可能会使用类似下面的内容
var a = 3;
var b = "Some string";
var c = a + b;
问题:上面代码的问题在于它没有使用支持两个 string
的 String.Concat()
方法的重载。使用的重载只支持两个对象。因此,第一个参数,一个整数,将不得不被装箱到对象——哎哟!Needless to say,这个问题有一个简单的解决方案
var a = 3;
var b = "Some string";
var c = a.ToString() + b;
这也是我们可能会遇到的问题,在我们自己的方法重载中。让我们考虑以下问题:我们有一个接口和一个实现该接口的结构。当然,我们希望通过最小化方法对输入参数的要求来鼓励低耦合。所以我们将设计我们的系统如下
interface IRun
{
void Run();
}
struct TestRun : IRun
{
public void Run()
{
"I am running ...".Dump();
}
}
void ExecuteRun(IRun runable)
{
runable.Run();
}
显然,这看起来是真正的好代码,不是吗?但是这段代码遇到了与之前 string
连接相同的问题。但是我们如何在这种情况下支持结构呢?有办法做到吗?
解决方案:泛型功能强大,但考虑到类型安全和智能感知,实现起来非常困难。因此,C# 引入了约束的概念,这对于这个问题非常有用。而不是像以前那样编写方法,我们将允许相同方法的副本。这些副本将由 C# 编译器生成,适用于实现我们的接口并使用该方法的每种类型。
代码可能看起来像这样
void ExecuteRun<T>(T runable) where T : IRun
{
runable.Run();
}
所以,虽然这个解决方案对类和结构都有效,但上面的解决方案只对类有效。在上面的代码中,结构将被装箱,从而产生一些可能不需要的开销,如果我们寻求最佳性能的话。
→ 示例代码 boxing.linq
兴趣点
虽然其中一些技巧肯定在程序员的生命周期中只对一小部分程序员有用(至少对于平均程序员而言),但另一些技巧却是真正的救星。我认为通过使用扩展方法可用的可能性是 C# 最伟大的成就之一。
其中一些技巧极具争议,尤其是关于 sealed
的部分。我在这方面的观点似乎已经形成。密封很好,但你应该有一个理由去做。如果你不知道是否应该做,那么你可能就不应该做。最后,人们肯定可以说 API/库编写者应该比其他人更多地考虑这一点。对于写应用程序的人,我可能会推荐“别想了,直接跳过”。
参考文献
以下列表包含一些本文中发现的技巧的引用,或一般性阅读材料。其中一些链接可能还包含其他技巧,我认为这些技巧过于明显或不太有用,但它们对您来说仍然可能很有趣。
- 你可能不知道的关于 C# 的 8 件事
- StackOverflow:C# 的隐藏特性?
- 你不应该使用的 10 个 C# 关键字
- 开发者不应该做的另外 6 件事
- 未文档化的 C# 类型和关键字
- StackOverflow:RealProxy 允许你创建自己的现有类型代理
- StackOverflow:使用泛型避免装箱
- 关于 sealed 关键字的漫谈
- StackOverflow:为什么 sealed 类型更快?
- StackOverflow:最有用的属性
- 使用 RealProxy 创建代理
历史
- v1.0.0 | 初始发布 | 2013.12.08
- v1.0.1 | 修复了
StructLayout
技巧中的代码 | 2013.12.09 - v1.0.2 | 修复了一些拼写错误 | 2013.12.11
- v1.0.3 | 一些语法修正 | 2013.12.17