再谈扩展方法,从string.IsNullOrEmpty()说起
目录
string.IsNullOrEmpty()这个方法算得上是.net中使用频率最高的方法之一。此方法是string的一个静态方法,类似的静态方法在string这个类中还有很多。那么这样的方法作为静态方法是否合理呢?如果我们从面向对象的角度出发,我们会发现这种方案不是十分符合面向对象的设计原则。
什么是对象?对象是拥有数据和行为的结合体。如果说string是一个类,那么string message="hello"这句话就定义了一个string的对象,名称叫做message。
一.让对象自己说话
对象应该是自治的,它拥有自己的行为和数据。如果把对象当作一个生命体,他是可以自己说话的。比如我们把message当作一个人,就可以出现下面的对话:
A:“hi message,你是空的吗?"
B:"不是。"
A:"你的长度是多少啊?"
B:"5"
这样的对话体现了message作为一个对象,拥有自己的行为和数据。
而代码string.IsNullOrEmpty(message) 则描述了以下对话:
A:“Hi string, message是空的吗?”
B:“不是”
很显然,后面的对话借助于string类来完成本应该有message对象自己应该完成的事情。所以我们说这样的设计并没有完全符合面向对象的设计原则。如果说这样的设计还可以说的过去,毕竟string类和message对象是有那么一点关系。那么下面的这种场景则更加不靠谱:
你想对一个字符串实现反转(reverse),翻开string类查看了一番,发现.net并没有此方法,于是你创建了一个StringHelper的类,写下了下面的代码:
1 2 3 4 | public static string Reverse( string originalString) { return something; } |
如果代码会说话,则会有如下对话:
A:"hi StringHelper, 把message反转一下"
这样的对话暴露了两个问题:
- 耦合了StringHelper类,使用者必须要知道存在这样的一个类,使用者知道的太多。
- 反转自己是对象自己的行为,但自己并没有实现。
经过扩展方法的"补救”,代码可以写为:message.IsNullOrEmpty(),这不就是我们想要的结果吗? Ruby作为一门面向对象的语言,在设计之初就注意到了这个问题:在irb里输入:String.instance_methods(false)可以看到String的所有实例方法,例如:empty?,:size,:reverse,在Ruby里可以直接写:message.empty?
实际上,用“补救”一词并不准确,因为任何人都不能在设计之初考虑到对象的所有行为,扩展方法更多的是提供了一种我们扩展用户行为的方案。新型的编程语言:诸如F#,Ruby,swift等均提供了对象扩展的能力。
扩展方法可以完美的解决对象行为的扩展问题。你的项目里有没有诸如**Helper, **Utility之类的类?里面的代码大多可以成为某个对象的扩展方法。
2.模拟中缀运算符
C#中的+、-、*、/ 等运算符均为中缀表达式,比如要使用运算符"+"连接三个字符串:
1 | "stringA" + "stringB" + "stringC" ; |
如果使用函数则需要写成:
1 | string .Contact(“stringA”, string .Contact(“stringB”,”stringC”)); |
后一种写法的问题在于运算的书写顺序与其实际执行顺序相反,因此使用运算符而不使用函数的好处在于“中缀”运算符描述的代码阅读起来更为自然。利用扩展方法可以在一定程度上模拟中缀运算符。
Rectangle里有一个Union的静态方法:public static Rectangle Union(Rectangle a, Rectangle b); 我们很难说Union这个静态方法应该是Rectangle的一个行为。这个方法更多的表现出了两个Rectangle在做Union运算,利用扩展方法:
1 2 3 4 | internal static Rectangle Union( this Rectangle @ this ,Rectangle anotheRectangle) { return Rectangle.Union(@ this , notheRectangle); } |
可以很自然的写出:
1 | var unionRegion = r1.Union(r2).Union(r3); |
而不是:
1 | var unionRegion = Rectangle.Union(r1, Rectangle.Union(r2, r3)); |
三、面向语言编程
考虑下面的扩展方法和调用:
1 2 3 4 5 6 | public static TimeSpan Days( this int @ this ) { return TimeSpan.FromDays(@ this ); } var timeSpan = 3.Days(); |
在这种场景下,Days()既不是int的行为,也不是运算,而是具有一点面向语言编程的味道。在面向对象编程环境中,有一种编程风格叫做流畅接口(Fluent API),这样的编程场景大多用于类库的API设计,关于Fluent Interface的设计请看使用C#设计Fluent Interface和在C#中使用装饰器模式和扩展方法实现Fluent Interface。
四、泛型扩展方法
扩展方法在本质上是一个静态方法,因此也可以写出泛型扩展,简单举两个例子。
1 2 3 4 5 6 7 8 9 10 | public static T ChangeTo<T>( this object @ this ) { var value = default (T); value = (T)Convert.ChangeType(@ this , typeof (T)); return value; } var numberString = 2.ChangeTo< string >(); var numberBool = 2.ChangeTo< bool >(); |
例2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public static TValue GetOrAdd<TKey, TValue>( this IDictionary<TKey, TValue> me, TKey key, Func<TValue> constructor) { TValue value; if (me.TryGetValue(key, out value)) { return value; } value = constructor(); me.Add(key, value); return value; } var books = new Dictionary< int , string >(); var book = books.GetOrAdd(1, () => "book1" ); var book2 = books.GetOrAdd(1, () => "book1" ); |
这样的扩展方法能使代码更加简洁。
五、一种新的面向对象设计方案
如果说上面的场景仅仅代表代码层面的技巧,那么下面的技巧则是体现了扩展方法在面向对象中的一种的新设计方案。
场景:一辆摩托车和一辆自行车。其中鸣笛(Whistle)和读取实时速度(ReadSpeed)具有相同的实现,而刹车(Brake)和加速(AddSpeed)则各自具有不同的实现方式。
根据这样的场景我们立刻可以设计出这样的继承层次:
为了抽取鸣笛(Whistle)和读取实时速度(ReadSpeed)这两个行为并且公用,我们抽象了一个Vehicle作为抽象类。这样的设计也许是正确的,但是作为一个有经验的OO开发者也许不会马上赞同这个方案。原因有3:
- 因为要公用代码就立即设计类的继承关系不具有说服力,特别是在设计初期,抽象出的Vehicle并不一定准确,很多公用的行为放在Vehicle里久而久之违反了SRP(单一职责),进一步违反OCP(开放封闭原则)
- 面向对象编程中有一个指导性的原则叫做:使用组合而非继承。这个原则告诉我们组合比继承更加灵活,没有十足的把握不要使用继承。
- 只有非常确定继承关系,并且继承关系符合LSP(里氏替换原则)时,才会认为这个抽象类设计的没有问题。
利用扩展方法的方案如下:抽象出接口ICanRun,用来实现刹车(Brake)和加速(AddSpeed)。将鸣笛(Whistle)和读取实时速度(ReadSpeed)两个公共的实现扩展在了ICanRun接口上,整个实现非常松耦合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | internal interface ICanRun { void Brake(); void AddSpeed(); } internal class Bicycle : ICanRun { public void Brake() { } public void AddSpeed() { } } internal class Motor : ICanRun { public void Brake() { } public void AddSpeed() { } } internal static class VehicleState { internal static void Whistle( this ICanRun @ this ) { } internal static void ReadSpeed( this ICanRun @ this ) { } } |
六:利用扩展方法写出混搭风格的代码
我们现在看微软对扩展方法的定义:扩展方法使您能够向现有类型“添加”方法,而无需创建新的派生类型、重新编译或以其他方式修改原始类型。这里的“添加”之所以使用引号,是因为并没有真正地向指定类型添加方法。
这个定义更多的是在说明扩展方法的实现方式,但是也会带来一个误解:在有源代码的情况下无需使用扩展方法。通过上面几种场景我们可以看出扩展方法的存在不仅仅是为了“添加”方法,灵活使用扩展方法有助于写出简洁、富有表达力的代码。比较典型的案例为:owin katana,asp.net core 1.0,这两个项目中大量使用了扩展方法,这种混搭风格的代码提高了代码的扩展性,易于阅读。
举个例子,刚开始我们有这样的一个设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public interface ISprite { void Move(); void Stop(); void Speak(); } public class Sprite:ISprite { public void Move() { throw new NotImplementedException(); } public void Stop() { throw new NotImplementedException(); } public void Speak() { throw new NotImplementedException(); } } |
接口ISprite定义的一尘不染,定义了Sprite应该具有的核心能力,这样的代码对于阅读者而言一目了然。不过随着业务的发展,我们需要对Sprite序列化和反序列化,还要给Sprite添加一些属性等待各种需求。这些能力应该是Sprite对象应该具备的,定义在Sprite类中无可厚非,但是这样一来Sprite类急剧膨胀,阅读性也变的差了起来,阅读序列化和反序列化这样的代码几乎不会增加阅读者对Sprite类的理解,所以我们完全可以将这些能力分别归类扩展在Sprite上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public static class SpriteSerilization { public static byte [] Serilize( this ISprite sprite) { return null ; } public static ISprite Deserilize( this byte [] bytes, string name) { return null ; } } public static class SpriteClassifier { public static bool IsBad( this ISprite sprite) { return true ; } public static bool IsGood( this ISprite sprite) { return false ; } } |
这两个扩展类帮我们对Sprite不太核心的能力做了分类,SpriteSerilization和SpriteClassifier对能力做了归类。在我来看这样的设计更利于阅读和维护,你觉得呢?
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· 展开说说关于C#中ORM框架的用法!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?