C#利用插值字符串處理器寫一個sscanf
前言
什么?用 C# 插值字符串處理器寫一個輸入用的 sscanf
?你確定不是輸出用的 sprintf
?
我猜不少讀者看到標(biāo)題后大概會有上述的想法。然而我們這里還真就是實現(xiàn) sscanf
,而不是 sprintf
。
插值字符串處理器
C# 有一個特性叫做插值字符串,使用插值字符串,你可以自然地往字符串里面插入變量的值,比如:$"abc{x}def"
,這一改以往通過 string.Format
來格式化字符串的方式,使得不再需要先傳遞一個字符串模板再挨個傳遞參數(shù),非常方便。
在插值字符串的基礎(chǔ)上更進一步,C# 支持插值字符串處理器,意味著你可以自定義字符串的插值行為。比如一個簡單的例子:
[InterpolatedStringHandler] struct Handler(int literalLength, int formattedCount) { public void AppendLiteral(string s) { Console.WriteLine($"Literal: '{s}'"); } public void AppendFormatted<T>(T v) { Console.WriteLine($"Value: '{v}'"); } }
在使用的時候,只需要把傳遞 string
參數(shù)的地方都換成這個 Handler
類型,就能做到按照你自定義的方式來處理插值字符串,我們的插值字符串會被 C# 編譯器自動變換成 Handler
的構(gòu)造和調(diào)用然后被傳入:
void Foo(Handler handler) { } var x = 42; Foo($"abc{x}def");
比如上面這個例子,你會得到輸出:
Literal: 'abc'
Value: '42'
Literal: 'def'
這大大方便了各種結(jié)構(gòu)化日志框架的處理,你只需要簡單的把插值字符串傳遞進去,日志框架就能根據(jù)你插值的方式來做到結(jié)構(gòu)化解析,從而完全避免了手動去格式化字符串。
帶參數(shù)的插值字符串處理器
其實 C# 的插值字符串處理器還支持帶額外的參數(shù):
[InterpolatedStringHandler] struct Handler(int literalLength, int formattedCount, int value) { public void AppendLiteral(string s) { Console.WriteLine($"Literal: '{s}'"); } public void AppendFormatted<T>(T v) { Console.WriteLine($"Value: '{v}'"); } } void Foo(int value, [InterpolatedStringHandlerArgument("value")] Handler handler) { } Foo(42, $"abc{x}def");
這么一來,42
就會被傳入 handler
的 value
參數(shù)當(dāng)中,這允許我們捕獲來自調(diào)用方的上下文,畢竟在日志場景中,根據(jù)不同參數(shù)來決定不同的格式很常見。
sscanf?
眾所周知 C/C++ 里面有一個很常用的函數(shù) sscanf
,它接受一個文本輸入和一個格式化模板,然后再傳遞對格式化部分的變量的引用,就能把變量的值解析出來:
const char* input = "test 123 test"; const char* template = "test %d test"; int v = 0; sscanf(input, template, &v); printf("%d\n", v); // 123
那我們能不能在 C# 里復(fù)刻一個呢?當(dāng)然可以!只不過需要一點點黑魔法。
用 C# 實現(xiàn) sscanf
首先我們做一個帶參數(shù)的插值字符串處理器:
[InterpolatedStringHandler] ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input) { private ReadOnlySpan<char> _input = input; public void AppendLiteral(ReadOnlySpan<char> s) { } public void AppendFormatted<T>(T v) where T : ISpanParsable<T> { } }
這里我們把所有的 string
都換成 ReadOnlySpan<char>
減少分配。
按照 sscanf
的使用方法,我們按理來說應(yīng)該做成類似這樣的東西:
void sscanf(ReadOnlySpan<char> input, ReadOnlySpan<char> template, params object[] args);
但是很顯然,這里我們需要的是 (ref object)[]
,因為我們需要傳遞引用進去才能做到對外部變量的更新,而不是直接把變量的值當(dāng)作 object
傳進去。那怎么辦呢?
你會發(fā)現(xiàn),C# 的插值字符串處理器里已經(jīng)包含了各變量的值,因此我們完全不需要像 C/C++ 那樣通過類似 %d
之類的占位符來插入變量!相對于 "test %d test"
我們可以直接寫 $"test {v} test"
,然后通過引用傳遞這個 v
。
一個很自然的想法是,我們把只需要把 AppendFormatted<T>(T v)
改成 AppendFormatted<T>(ref T v)
不就行了。
然而實際這么操作之后你會發(fā)現(xiàn)這么做是行不通的:
[InterpolatedStringHandler] ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input) { private ReadOnlySpan<char> _input = input; public void AppendLiteral(ReadOnlySpan<char> s) { } public void AppendFormatted<T>(ref T v) where T : ISpanParsable<T> { } } void sscanf(ReadOnlySpan<char> input, [InterpolatedStringHandlerArgument("input")] TemplatedStringHandler template);
當(dāng)我們試圖調(diào)用 sscanf
的時候:
int v = 0; sscanf("test 123 test", $"test {ref v} test"); // error CS1525: Invalid expression term 'ref'
報錯了!插值字符串的值部分里寫 ref
關(guān)鍵字是無效的!
注意到這個錯誤是來自 C# 編譯器的 parser,也就是說只要我們從語法上把這個 ref
干掉,那就能通過編譯了。
此時我們靈機一動,我們 C# 不是有 in
來傳遞只讀引用嗎?C# 對于 in
傳遞只讀引用會自動幫我們創(chuàng)建引用并傳遞進去,無需在語法上顯式指定 ref
,于是我們稍微利用一下這個特性改造一番:
[InterpolatedStringHandler] ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input) { private ReadOnlySpan<char> _input = input; public void AppendLiteral(ReadOnlySpan<char> s) { } public void AppendFormatted<T>(in T v) where T : ISpanParsable<T> { } }
然后就會發(fā)現(xiàn),下面這個代碼可以成功編譯了:
int v = 0; sscanf("test 123 test", $"test {v} test");
此時我們離成功只剩下最后一步:傳遞進來的是只讀引用,可是為了提取出變量我們需要更新引用的值,怎么辦呢?
好在我們有 Unsafe.AsRef
把只讀引用轉(zhuǎn)換成可變引用,那最后一個問題解決了,我們就可以開始我們的實現(xiàn)了。
[InterpolatedStringHandler] ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input) { private int _index = 0; private ReadOnlySpan<char> _input = input; public void AppendLiteral(ReadOnlySpan<char> s) { var offset = Advance(0); // 先跳過連續(xù)空白字符 _input = _input[offset..]; _index += offset; if (_input.StartsWith(s)) // 從輸入字符串中去掉模板字符串的非變量部分 { _input = _input[s.Length..]; } else throw new FormatException($"Cannot find '{s}' in the input string (at index: {_index})."); _index += s.Length; literalLength -= s.Length; } public void AppendFormatted<T>(in T v) where T : ISpanParsable<T> { var offset = Advance(0); // 先跳過連續(xù)空白字符 _input = _input[offset..]; _index += offset; var length = Scan(); // 計算到下一個空白字符為止的長度 if (T.TryParse(_input[..length], null, out var result)) // 解析! { Unsafe.AsRef(in v) = result; // 把只讀引用換成可變引用后更新引用值 _input = _input[length..]; _index += length; formattedCount--; } else { throw new FormatException($"Cannot parse '{_input[..length]}' to '{typeof(T)}' (at index: {_index})."); } } // 向后掃描,直到遇到空白字符停止 private int Scan() { var length = 0; for (var i = 0; i < _input.Length; i++) { if (_input[i] is ' ' or '\t' or '\r' or '\n') break; length++; } return length; } // 跳過所有的空白字符 private int Advance(int start) { var length = start; while (length < _input.Length && _input[length] is ' ' or '\t' or '\r' or '\n') { length++; } return length; } }
然后我們提供一個 sscanf
暴露我們的插值字符串處理器即可:
static void sscanf(ReadOnlySpan<char> input, [InterpolatedStringHandlerArgument("input")] TemplatedStringHandler template) { }
使用
int x = 0; string y = ""; bool z = false; DateTime d = default; sscanf("test 123 hello false 2025/01/01T00:00:00 end", $"test{x}{y}{z}vvxyksv9kdend"); Console.WriteLine(x); Console.WriteLine(y); Console.WriteLine(z); Console.WriteLine(d);
得到輸出:
123
hello
False
2025年1月1日 0:00:00
而 scanf
只不過是 sscanf(Console.ReadLine(), template)
的簡寫罷了,所以這里我們有 sscanf
就完全足夠了。
結(jié)論
C# 的插值字符串處理器非常強大,利用這個特性,我們成功實現(xiàn)了比 C/C++ 中 sscanf
還要更好用的多的字符串解析函數(shù),不僅不需要格式化字符串占位,還能自動推導(dǎo)類型,甚至連在后面的參數(shù)里逐個傳遞變量引用的需要都直接省掉了,在此基礎(chǔ)上我們還做到了零分配。
到此這篇關(guān)于C#利用插值字符串處理器寫一個sscanf的文章就介紹到這了,更多相關(guān)C#插值字符串內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章

C#調(diào)用sql2000存儲過程方法小結(jié)