用 C# 插值字符串处理器写一个 sscanf

张春  金牌会员 | 2025-2-16 16:26:22 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 982|帖子 982|积分 2946

前言

什么?用 C# 插值字符串处理器写一个输入用的 sscanf?你确定不是输出用的 sprintf?
我猜不少读者看到标题后大概会有上述的想法。然而我们这里还真就是实现 sscanf,而不是 sprintf。
插值字符串处理器

C# 有一个特性叫做插值字符串,使用插值字符串,你可以自然地往字符串里面插入变量的值,比如:$"abc{x}def",这一改以往通过 string.Format 来格式化字符串的方式,使得不再必要先传递一个字符串模板再挨个传递参数,非常方便。
在插值字符串的基础上更进一步,C# 支持插值字符串处理器,意味着你可以自定义字符串的插值行为。比如一个简单的例子:
  1. [InterpolatedStringHandler]
  2. struct Handler(int literalLength, int formattedCount)
  3. {
  4.     public void AppendLiteral(string s)
  5.     {
  6.         Console.WriteLine($"Literal: '{s}'");
  7.     }
  8.     public void AppendFormatted<T>(T v)
  9.     {
  10.         Console.WriteLine($"Value: '{v}'");
  11.     }
  12. }
复制代码
在使用的时候,只必要把传递 string 参数的地方都换成这个 Handler 类型,就能做到按照你自定义的方式来处理插值字符串,我们的插值字符串会被 C# 编译器自动变换成 Handler 的构造和调用然后被传入:
  1. void Foo(Handler handler) { }
  2. var x = 42;
  3. Foo($"abc{x}def");
复制代码
比如上面这个例子,你会得到输出:
  1. Literal: 'abc'
  2. Value: '42'
  3. Literal: 'def'
复制代码
这大大方便了各种结构化日记框架的处理,你只必要简单的把插值字符串传递进去,日记框架就能根据你插值的方式来做到结构化解析,从而完全制止了手动去格式化字符串。
带参数的插值字符串处理器

实在 C# 的插值字符串处理器还支持带额外的参数:
  1. [InterpolatedStringHandler]
  2. struct Handler(int literalLength, int formattedCount, int value)
  3. {
  4.     public void AppendLiteral(string s)
  5.     {
  6.         Console.WriteLine($"Literal: '{s}'");
  7.     }
  8.     public void AppendFormatted<T>(T v)
  9.     {
  10.         Console.WriteLine($"Value: '{v}'");
  11.     }
  12. }
  13. void Foo(int value, [InterpolatedStringHandlerArgument("value")] Handler handler) { }
  14. Foo(42, $"abc{x}def");
复制代码
这么一来,42 就会被传入 handler 的 value 参数当中,这允许我们捕捉来自调用方的上下文,毕竟在日记场景中,根据不同参数来决定不同的格式很常见。
sscanf?

众所周知 C/C++ 里面有一个很常用的函数 sscanf,它接受一个文本输入和一个格式化模板,然后再传递对格式化部分的变量的引用,就能把变量的值解析出来:
  1. const char* input = "test 123 test";
  2. const char* template = "test %d test";
  3. int v = 0;
  4. sscanf(input, template, &v);
  5. printf("%d\n", v); // 123
复制代码
那我们能不能在 C# 里复刻一个呢?当然可以!只不过必要一点点黑魔法。
用 C# 实现 sscanf

起首我们做一个带参数的插值字符串处理器:
  1. [InterpolatedStringHandler]
  2. ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
  3. {
  4.     private ReadOnlySpan<char> _input = input;
  5.     public void AppendLiteral(ReadOnlySpan<char> s)
  6.     {
  7.     }
  8.     public void AppendFormatted<T>(T v) where T : ISpanParsable<T>
  9.     {
  10.     }
  11. }
复制代码
这里我们把所有的 string 都换成 ReadOnlySpan 减少分配。
按照 sscanf 的使用方法,我们按理来说应该做成雷同这样的东西:
  1. 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 v) 改成 AppendFormatted(ref T v) 不就行了。
然而现实这么操纵之后你会发现这么做是行不通的:
  1. [InterpolatedStringHandler]
  2. ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
  3. {
  4.     private ReadOnlySpan<char> _input = input;
  5.     public void AppendLiteral(ReadOnlySpan<char> s)
  6.     {
  7.     }
  8.     public void AppendFormatted<T>(ref T v) where T : ISpanParsable<T>
  9.     {
  10.     }
  11. }
  12. void sscanf(ReadOnlySpan<char> input, [InterpolatedStringHandlerArgument("input")] TemplatedStringHandler template);
复制代码
当我们试图调用 sscanf 的时候:
  1. int v = 0;
  2. sscanf("test 123 test", $"test {ref v} test"); // error CS1525: Invalid expression term 'ref'
复制代码
报错了!插值字符串的值部分里写 ref 关键字是无效的!
注意到这个错误是来自 C# 编译器的 parser,也就是说只要我们从语法上把这个 ref 干掉,那就能通过编译了。
此时我们灵机一动,我们 C# 不是有 in 来传递只读引用吗?C# 对于 in 传递只读引用会自动帮我们创建引用并传递进去,无需在语法上显式指定 ref,于是我们稍微利用一下这个特性改造一番:
  1. [InterpolatedStringHandler]
  2. ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
  3. {
  4.     private ReadOnlySpan<char> _input = input;
  5.     public void AppendLiteral(ReadOnlySpan<char> s)
  6.     {
  7.     }
  8.     public void AppendFormatted<T>(in T v) where T : ISpanParsable<T>
  9.     {
  10.     }
  11. }
复制代码
然后就会发现,下面这个代码可以成功编译了:
  1. int v = 0;
  2. sscanf("test 123 test", $"test {v} test");
复制代码
此时我们离成功只剩下最后一步:传递进来的是只读引用,可是为了提取出变量我们必要更新引用的值,怎么办呢?
好在我们有 Unsafe.AsRef 把只读引用转换成可变引用,那最后一个问题解决了,我们就可以开始我们的实现了。
  1. [InterpolatedStringHandler]
  2. ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
  3. {
  4.     private int _index = 0;
  5.     private ReadOnlySpan<char> _input = input;
  6.     public void AppendLiteral(ReadOnlySpan<char> s)
  7.     {
  8.         var offset = Advance(0); // 先跳过连续空白字符
  9.         _input = _input[offset..];
  10.         _index += offset;
  11.   
  12.         if (_input.StartsWith(s)) // 从输入字符串中去掉模板字符串的非变量部分
  13.         {
  14.             _input = _input[s.Length..];
  15.         }
  16.         else throw new FormatException($"Cannot find '{s}' in the input string (at index: {_index}).");
  17.         _index += s.Length;
  18.         literalLength -= s.Length;
  19.     }
  20.     public void AppendFormatted<T>(in T v) where T : ISpanParsable<T>
  21.     {
  22.         var offset = Advance(0); // 先跳过连续空白字符
  23.         _input = _input[offset..];
  24.         _index += offset;
  25.         var length = Scan(); // 计算到下一个空白字符为止的长度
  26.         if (T.TryParse(_input[..length], null, out var result)) // 解析!
  27.         {
  28.             Unsafe.AsRef(in v) = result; // 把只读引用换成可变引用后更新引用值
  29.             _input = _input[length..];
  30.             _index += length;
  31.             formattedCount--;
  32.         }
  33.         else
  34.         {
  35.             throw new FormatException($"Cannot parse '{_input[..length]}' to '{typeof(T)}' (at index: {_index}).");
  36.         }
  37.     }
  38.     // 向后扫描,直到遇到空白字符停止
  39.     private int Scan()
  40.     {
  41.         var length = 0;
  42.         for (var i = 0; i < _input.Length; i++)
  43.         {
  44.             if (_input[i] is ' ' or '\t' or '\r' or '\n') break;
  45.             length++;
  46.         }
  47.         return length;
  48.     }
  49.     // 跳过所有的空白字符
  50.     private int Advance(int start)
  51.     {
  52.         var length = start;
  53.         while (length < _input.Length && _input[length] is ' ' or '\t' or '\r' or '\n')
  54.         {
  55.             length++;
  56.         }
  57.         return length;
  58.     }
  59. }
复制代码
然后我们提供一个 sscanf 暴露我们的插值字符串处理器即可:
  1. static void sscanf(ReadOnlySpan<char> input, [InterpolatedStringHandlerArgument("input")] TemplatedStringHandler template) { }
复制代码
使用
  1. int x = 0;
  2. string y = "";
  3. bool z = false;
  4. DateTime d = default;
  5. sscanf("test 123 hello false 2025/01/01T00:00:00 end", $"test{x}{y}{z}{d}end");
  6. Console.WriteLine(x);
  7. Console.WriteLine(y);
  8. Console.WriteLine(z);
  9. Console.WriteLine(d);
复制代码
得到输出:
  1. 123
  2. hello
  3. False
  4. 2025年1月1日 0:00:00
复制代码
而 scanf 只不过是 sscanf(Console.ReadLine(), template) 的简写罢了,所以这里我们有 sscanf 就完全足够了。
结论

C# 的插值字符串处理器非常强大,利用这个特性,我们成功实现了比 C/C++ 中 sscanf 还要更好用的多的字符串解析函数,不但不必要格式化字符串占位,还能自动推导类型,甚至连在后面的参数里逐个传递变量引用的必要都直接省掉了,在此基础上我们还做到了零分配。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

张春

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表