前言
什么?用 c# 插值字符串处理器写一个输入用的 sscanf?你确定不是输出用的 sprintf?
我猜不少读者看到标题后大概会有上述的想法。然而我们这里还真就是实现 sscanf,而不是 sprintf。
插值字符串处理器
c# 有一个特性叫做插值字符串,使用插值字符串,你可以自然地往字符串里面插入变量的值,比如:$"abc{x}def",这一改以往通过 string.format 来格式化字符串的方式,使得不再需要先传递一个字符串模板再挨个传递参数,非常方便。
在插值字符串的基础上更进一步,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 参数的地方都换成这个 handler 类型,就能做到按照你自定义的方式来处理插值字符串,我们的插值字符串会被 c# 编译器自动变换成 handler 的构造和调用然后被传入:
void foo(handler handler) { }
var x = 42;
foo($"abc{x}def");比如上面这个例子,你会得到输出:
literal: 'abc'
value: '42'
literal: 'def'
这大大方便了各种结构化日志框架的处理,你只需要简单的把插值字符串传递进去,日志框架就能根据你插值的方式来做到结构化解析,从而完全避免了手动去格式化字符串。
带参数的插值字符串处理器
其实 c# 的插值字符串处理器还支持带额外的参数:
[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 参数当中,这允许我们捕获来自调用方的上下文,毕竟在日志场景中,根据不同参数来决定不同的格式很常见。
sscanf?
众所周知 c/c++ 里面有一个很常用的函数 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# 里复刻一个呢?当然可以!只不过需要一点点黑魔法。
用 c# 实现 sscanf
首先我们做一个带参数的插值字符串处理器:
[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 的使用方法,我们按理来说应该做成类似这样的东西:
void sscanf(readonlyspan<char> input, readonlyspan<char> template, params object[] args);
但是很显然,这里我们需要的是 (ref object)[],因为我们需要传递引用进去才能做到对外部变量的更新,而不是直接把变量的值当作 object 传进去。那怎么办呢?
你会发现,c# 的插值字符串处理器里已经包含了各变量的值,因此我们完全不需要像 c/c++ 那样通过类似 %d 之类的占位符来插入变量!相对于 "test %d test" 我们可以直接写 $"test {v} test",然后通过引用传递这个 v。
一个很自然的想法是,我们把只需要把 appendformatted<t>(t v) 改成 appendformatted<t>(ref t v) 不就行了。
然而实际这么操作之后你会发现这么做是行不通的:
[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);当我们试图调用 sscanf 的时候:
int v = 0;
sscanf("test 123 test", $"test {ref v} test"); // error cs1525: invalid expression term 'ref'报错了!插值字符串的值部分里写 ref 关键字是无效的!
注意到这个错误是来自 c# 编译器的 parser,也就是说只要我们从语法上把这个 ref 干掉,那就能通过编译了。
此时我们灵机一动,我们 c# 不是有 in 来传递只读引用吗?c# 对于 in 传递只读引用会自动帮我们创建引用并传递进去,无需在语法上显式指定 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>
{
}
}然后就会发现,下面这个代码可以成功编译了:
int v = 0;
sscanf("test 123 test", $"test {v} test");此时我们离成功只剩下最后一步:传递进来的是只读引用,可是为了提取出变量我们需要更新引用的值,怎么办呢?
好在我们有 unsafe.asref 把只读引用转换成可变引用,那最后一个问题解决了,我们就可以开始我们的实现了。
[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); // 先跳过连续空白字符
_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); // 先跳过连续空白字符
_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}{d}end");
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 就完全足够了。
结论
c# 的插值字符串处理器非常强大,利用这个特性,我们成功实现了比 c/c++ 中 sscanf 还要更好用的多的字符串解析函数,不仅不需要格式化字符串占位,还能自动推导类型,甚至连在后面的参数里逐个传递变量引用的需要都直接省掉了,在此基础上我们还做到了零分配。
到此这篇关于c#利用插值字符串处理器写一个sscanf的文章就介绍到这了,更多相关c#插值字符串内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论