C#中的9個“黑魔法”
C#中的9個“黑魔法”與“騷操作”
我們知道C#
是非常先進的語言,因為是它很有遠見的“語法糖”。這些“語法糖”有時過于好用,導致有人覺得它是C#
編譯器寫死的東西,沒有道理可講的——有點像“黑魔法”。
那么我們可以看看C#
這些高級語言功能,是編譯器寫死的東西(“黑魔法”),還是可以擴展(騷操作)的“鴨子類型”。
我先列一個目錄,大家可以對著這個目錄試著下判斷,說說是“黑魔法”(編譯器寫死),還是“鴨子類型”(可以自定義“騷操作”):
-
LINQ
操作,與IEnumerable<T>
類型; -
async/await
,與Task
/ValueTask
類型; - 表達式樹,與
Expression<T>
類型; - 插值字符串,與
FormattableString
類型; -
yield return
,與IEnumerable<T>
類型; -
foreach
循環,與IEnumerable<T>
類型; -
using
關鍵字,與IDisposable
接口; -
T?
,與Nullable<T>
類型; - 任意類型的
Index/Range
泛型操作。
1. LINQ
操作,與IEnumerable<T>
類型
不是“黑魔法”,是“鴨子類型”。
LINQ
是C# 3.0
發布的新功能,可以非常便利地操作數據。現在12
年過去了,雖然有些功能有待增強,但相比其它語言還是方便許多。
如我上一篇博客提到,LINQ
不一定要基于IEnumerable<T>
,只需定定義一個類型,實現所需要的LINQ
表達式即可,LINQ
的select
關鍵字,會調用.Select
方法,可以用如下的“騷操作”,實現“移花接木”的效果:
void Main()
{
var query =
from i in new F()
select 3;
Console.WriteLine(string.Join(",", query)); // 0,1,2,3,4
}
class F
{
public IEnumerable<int> Select<R>(Func<int, R> t)
{
for (var i = 0; i < 5; ++i)
{
yield return i;
}
}
}
2. async/await
,與Task
/ValueTask
類型
不是“黑魔法”,是“鴨子類型”。
async/await
發布于C# 5.0
,可以非常便利地做異步編程,其本質是狀態機。
async/await
的本質是會尋找類型下一個名字叫GetAwaiter()
的接口,該接口必須返回一個繼承于INotifyCompletion
或ICriticalNotifyCompletion
的類,該類還需要實現GetResult()
方法和IsComplete
屬性。
這一點在C#
語言規范中有說明,調用await t
本質會按如下順序執行:
- 先調用
t.GetAwaiter()
方法,取得等待器a
; - 調用
a.IsCompleted
取得布爾類型b
; - 如果
b=true
,則立即執行a.GetResult()
,取得運行結果; - 如果
b=false
,則看情況:- 如果
a
沒實現ICriticalNotifyCompletion
,則執行(a as INotifyCompletion).OnCompleted(action)
- 如果
a
實現了ICriticalNotifyCompletion
,則執行(a as ICriticalNotifyCompletion).OnCompleted(action)
- 執行隨后暫停,
OnCompleted
完成后重新回到狀態機;
- 如果
有興趣的可以訪問Github
具體規范說明:https://github.com/dotnet/csharplang/blob/master/spec/expressions.md#runtime-evaluation-of-await-expressions
正常Task.Delay()
是基于線程池計時器
的,可以用如下“騷操作”,來實現一個單線程的TaskEx.Delay()
:
static Action Tick = null;
void Main()
{
Start();
while (true)
{
if (Tick != null) Tick();
Thread.Sleep(1);
}
}
async void Start()
{
Console.WriteLine("執行開始");
for (int i = 1; i <= 4; ++i)
{
Console.WriteLine($"第{i}次,時間:{DateTime.Now.ToString("HH:mm:ss")} - 線程號:{Thread.CurrentThread.ManagedThreadId}");
await TaskEx.Delay(1000);
}
Console.WriteLine("執行完成");
}
class TaskEx
{
public static MyDelay Delay(int ms) => new MyDelay(ms);
}
class MyDelay : INotifyCompletion
{
private readonly double _start;
private readonly int _ms;
public MyDelay(int ms)
{
_start = Util.ElapsedTime.TotalMilliseconds;
_ms = ms;
}
internal MyDelay GetAwaiter() => this;
public void OnCompleted(Action continuation)
{
Tick += Check;
void Check()
{
if (Util.ElapsedTime.TotalMilliseconds - _start > _ms)
{
continuation();
Tick -= Check;
}
}
}
public void GetResult() {}
public bool IsCompleted => false;
}
運行效果如下:
執行開始
第1次,時間:17:38:03 - 線程號:1
第2次,時間:17:38:04 - 線程號:1
第3次,時間:17:38:05 - 線程號:1
第4次,時間:17:38:06 - 線程號:1
執行完成
注意不需要非得使用
TaskCompletionSource<T>
才能創建定定義的async/await
。
3. 表達式樹,與Expression<T>
類型
是“黑魔法”,沒有“操作空間”,只有當類型是Expression<T>
時,才會創建為表達式樹。
表達式樹
是C# 3.0
隨著LINQ
一起發布,是有遠見的“黑魔法”。
如以下代碼:
Expression<Func<int>> g3 = () => 3;
會被編譯器翻譯為:
Expression<Func<int>> g3 = Expression.Lambda<Func<int>>(
Expression.Constant(3, typeof(int)),
Array.Empty<ParameterExpression>());
4. 插值字符串,與FormattableString
類型
是“黑魔法”,沒有“操作空間”。
插值字符串
發布于C# 6.0
,在此之前許多語言都提供了類似的功能。
只有當類型是FormattableString
,才會產生不一樣的編譯結果,如以下代碼:
FormattableString x1 = $"Hello {42}";
string x2 = $"Hello {42}";
編譯器生成結果如下:
FormattableString x1 = FormattableStringFactory.Create("Hello {0}", 42);
string x2 = string.Format("Hello {0}", 42);
注意其本質是調用了FormattableStringFactory.Create
來創建一個類型。
5. yield return
,與IEnumerable<T>
類型;
是“黑魔法”,但有補充說明。
yield return
除了用于IEnumerable<T>
以外,還可以用于IEnumerable
、IEnumerator<T>
、IEnumerator
。
因此,如果想用C#
來模擬C++
/Java
的generator<T>
的行為,會比較簡單:
var seq = GetNumbers();
seq.MoveNext();
Console.WriteLine(seq.Current); // 0
seq.MoveNext();
Console.WriteLine(seq.Current); // 1
seq.MoveNext();
Console.WriteLine(seq.Current); // 2
seq.MoveNext();
Console.WriteLine(seq.Current); // 3
seq.MoveNext();
Console.WriteLine(seq.Current); // 4
IEnumerator<int> GetNumbers()
{
for (var i = 0; i < 5; ++i)
yield return i;
}
yield return
——“迭代器”發布于C# 2.0
。
6. foreach
循環,與IEnumerable<T>
類型
是“鴨子類型”,有“操作空間”。
foreach
不一定非要配合使用IEnumerable<T>
類型,只要對象存在GetEnumerator()
方法即可:
void Main()
{
foreach (var i in new F())
{
Console.Write(i + ", "); // 1, 2, 3, 4, 5,
}
}
class F
{
public IEnumerator<int> GetEnumerator()
{
for (var i = 0; i < 5; ++i)
{
yield return i;
}
}
}
另外,如果對象實現了GetAsyncEnumerator()
,甚至也可以一樣使用await foreach
異步循環:
async Task Main()
{
await foreach (var i in new F())
{
Console.Write(i + ", "); // 1, 2, 3, 4, 5,
}
}
class F
{
public async IAsyncEnumerator<int> GetAsyncEnumerator()
{
for (var i = 0; i < 5; ++i)
{
await Task.Delay(1);
yield return i;
}
}
}
await foreach
是C# 8.0
隨著異步流
一起發布的,具體可見我之前寫的《代碼演示C#各版本新功能》。
7. using
關鍵字,與IDisposable
接口
是,也不是。
引用類型
和正常的值類型
用using
關鍵字,必須基于IDisposable
接口。
但ref struct
和IAsyncDisposable
就是另一個故事了,由于ref struct
不允許隨便移動,而引用類型——托管堆,會允許內存移動,所以ref struct
不允許和引用類型
產生任何關系,這個關系就包含繼承接口
——因為接口
也是引用類型
。
但釋放資源的需求依然存在,怎么辦,“鴨子類型”來了,可以手寫一個Dispose()
方法,不需要繼承任何接口:
void S1Demo()
{
using S1 s1 = new S1();
}
ref struct S1
{
public void Dispose()
{
Console.WriteLine("正常釋放");
}
}
同樣的道理,如果用IAsyncDisposable
接口:
async Task S2Demo()
{
await using S2 s2 = new S2();
}
struct S2 : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(1);
Console.WriteLine("Async釋放");
}
}
8. T?
,與Nullable<T>
類型
是“黑魔法”,只有Nullable<T>
才能接受T?
,Nullable<T>
作為一個值類型
,它還能直接接受null
值(正常值類型
不允許接受null
值)。
示例代碼如下:
int? t1 = null;
Nullable<int> t2 = null;
int t3 = null; // Error CS0037: Cannot convert null to 'int' because it is a non-nullable value type
生成代碼如下(int?
與Nullable<int>
完全一樣,跳過了編譯失敗的代碼):
IL_0000: nop
IL_0001: ldloca.s 0
IL_0003: initobj valuetype [System.Runtime]System.Nullable`1<int32>
IL_0009: ldloca.s 1
IL_000b: initobj valuetype [System.Runtime]System.Nullable`1<int32>
IL_0011: ret
9. 任意類型的Index/Range
泛型操作
有“黑魔法”,也有“鴨子類型”——存在操作空間。
Index/Range
發布于C# 8.0
,可以像Python
那樣方便地操作索引位置、取出對應值。以前需要調用Substring
等復雜操作的,現在非常簡單。
string url = "https://www.super-cool.com/product/7705a33a-4d2c-455d-a42c-c95e6ac8ee99/summary";
string productId = url[35..url.LastIndexOf("/")];
Console.WriteLine(productId);
生成代碼如下:
string url = "https://www.super-cool.com/product/7705a33a-4d2c-455d-a42c-c95e6ac8ee99/amd-r7-3800x";
int num = 35;
int length = url.LastIndexOf("/") - num;
string productId = url.Substring(num, length);
Console.WriteLine(productId); // 7705a33a-4d2c-455d-a42c-c95e6ac8ee99
可見,C#
編譯器忽略了Index/Range
,直接翻譯為調用Substring
了。
但數組又不同:
var range = new[] { 1, 2, 3, 4, 5 }[1..3];
Console.WriteLine(string.Join(", ", range)); // 2, 3
生成代碼如下:
int[] range = RuntimeHelpers.GetSubArray<int>(new int[5]
{
1,
2,
3,
4,
5
}, new Range(1, 3));
Console.WriteLine(string.Join<int>(", ", range));
可見它確實創建了Range
類型,然后調用了RuntimeHelpers.GetSubArray<int>
,完全屬于“黑魔法”。
但它同時也是“鴨子”類型,只要代碼中實現了Length
屬性和Slice(int, int)
方法,即可調用Index/Range
:
var range2 = new F()[2..];
Console.WriteLine(range2); // 2 -> -2
class F
{
public int Length { get; set; }
public IEnumerable<int> Slice(int start, int end)
{
yield return start;
yield return end;
}
}
生成代碼如下:
F f = new F();
int length2 = f.Length;
length = 2;
num = length2 - length;
string range2 = f.Slice(length, num);
Console.WriteLine(range2);
總結
如上所見,C#
的“黑魔法”確實挺多,但“鴨子類型”也有很多,“騷操作”的“操作空間”很大。