插值字符串处理器

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 就会被传入 handlervalue 参数当中,这允许我们捕获来自调用方的上下文,毕竟在日志场景中,根据不同参数来决定不同的格式很常见。

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<char> 减少分配。

按照 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>(T v) 改成 AppendFormatted<T>(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. if (_input.StartsWith(s)) // 从输入字符串中去掉模板字符串的非变量部分
  12. {
  13. _input = _input[s.Length..];
  14. }
  15. else throw new FormatException($"Cannot find '{s}' in the input string (at index: {_index}).");
  16. _index += s.Length;
  17. literalLength -= s.Length;
  18. }
  19. public void AppendFormatted<T>(in T v) where T : ISpanParsable<T>
  20. {
  21. var offset = Advance(0); // 先跳过连续空白字符
  22. _input = _input[offset..];
  23. _index += offset;
  24. var length = Scan(); // 计算到下一个空白字符为止的长度
  25. if (T.TryParse(_input[..length], null, out var result)) // 解析!
  26. {
  27. Unsafe.AsRef(in v) = result; // 把只读引用换成可变引用后更新引用值
  28. _input = _input[length..];
  29. _index += length;
  30. formattedCount--;
  31. }
  32. else
  33. {
  34. throw new FormatException($"Cannot parse '{_input[..length]}' to '{typeof(T)}' (at index: {_index}).");
  35. }
  36. }
  37. // 向后扫描,直到遇到空白字符停止
  38. private int Scan()
  39. {
  40. var length = 0;
  41. for (var i = 0; i < _input.Length; i++)
  42. {
  43. if (_input[i] is ' ' or '\t' or '\r' or '\n') break;
  44. length++;
  45. }
  46. return length;
  47. }
  48. // 跳过所有的空白字符
  49. private int Advance(int start)
  50. {
  51. var length = start;
  52. while (length < _input.Length && _input[length] is ' ' or '\t' or '\r' or '\n')
  53. {
  54. length++;
  55. }
  56. return length;
  57. }
  58. }

然后我们提供一个 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. 202511 0:00:00

scanf 只不过是 sscanf(Console.ReadLine(), template) 的简写罢了,所以这里我们有 sscanf 就完全足够了。

结论

C# 的插值字符串处理器非常强大,利用这个特性,我们成功实现了比 C/C++ 中 sscanf 还要更好用的多的字符串解析函数,不仅不需要格式化字符串占位,连引用传递的语法都直接省掉了。

用 C# 插值字符串处理器写一个 sscanf的更多相关文章

  1. 【C语言】写一个函数,实现字符串内单词逆序

    //写一个函数,实现字符串内单词逆序 //比如student a am i.逆序后i am a student. #include <stdio.h> #include <strin ...

  2. 4.写一个控制台应用程序,接收一个长度大于3的字符串,完成下列功能: 1)输出字符串的长度。 2)输出字符串中第一个出现字母a的位置。 3)在字符串的第3个字符后面插入子串“hello”,输出新字符串。 4)将字符串“hello”替换为“me”,输出新字符串。 5)以字符“m”为分隔符,将字符串分离,并输出分离后的字符串。 */

    namespace test4 {/* 4.写一个控制台应用程序,接收一个长度大于3的字符串,完成下列功能: 1)输出字符串的长度. 2)输出字符串中第一个出现字母a的位置. 3)在字符串的第3个字符 ...

  3. 38 写一个函数,求一个字符串的长度,在main函数中输入字符串,并输出其长度。

    题目:写一个函数,求一个字符串的长度,在main函数中输入字符串,并输出其长度. public class _038PrintLength { public static void main(Stri ...

  4. 已知有字符串foo=”get-element-by-id”,写一个function将其转化成驼峰表示法”getElementById”

    题目:已知有字符串foo=”get-element-by-id”,写一个function将其转化成驼峰表示法”getElementById”. 代码: <!DOCTYPE html> &l ...

  5. 写一个函数,求一个字符串的长度,在main函数中输入字符串,并输出其长度

    import java.util.Scanner; /** * [程序38] * * 题目:写一个函数,求一个字符串的长度,在main函数中输入字符串,并输出其长度. * * @author Jame ...

  6. 写一个函数,输入int型,返回整数逆序后的字符串

    刚刚看到一个面试题:写一个函数,输入int型,返回整数逆序后的字符串.如:输入123,返回"321". 要求必须用递归,不能用全局变量,输入必须是一个參数.必须返回字符串.&quo ...

  7. socket小程序写一个客户端,实现给服务端发送hello World字符串,将客户端发送的数据变成大写后返回

    写一个客户端,实现给服务端发送hello World字符串,将客户端发送的数据变成大写后返回 本机id是192.168.xx.xy 服务端 import socket soc = socket.soc ...

  8. 如何写一个简单的http服务器

    最近几天用C++写了一个简单的HTTP服务器,作为学习网络编程和Linux环境编程的练手项目,这篇文章记录我在写一个HTTP服务器过程中遇到的问题和学习到的知识. 服务器的源代码放在Github. H ...

  9. 从零开始写一个Tomcat(叁)--请求解析

    挖坑挖了这么长时间也该继续填坑了,上文书讲到从零开始写一个Tomcat(贰)--建立动态服务器,讲了如何让服务器解析请求,分离servlet请求和静态资源请求,读取静态资源文件输出或是通过URLCla ...

  10. 只有20行Javascript代码!手把手教你写一个页面模板引擎

    http://www.toobug.net/article/how_to_design_front_end_template_engine.html http://barretlee.com/webs ...

随机推荐

  1. ZJSU五月多校合训

    强度焦虑制造者 具体而言,zszz3在每个游戏版本中都会推出一名新角色,或加强一名旧角色.玩家必须将这名新角色或 被加强的旧角色编入队伍,否则就会落后于版本. 而编队数量是有限的,这意味着玩家可能不得 ...

  2. stylus图床

  3. Redis原理—5.性能和使用总结

    大纲 1.导致Redis阻塞的内在原因 2.导致Redis阻塞的外在原因 3.Redis的性能总结 4.Redis缓存的相关问题 5.数据库和缓存的一致性问题 6.数据库和缓存的一致性情况列举 1.导 ...

  4. Postman无法启动

    前情 最近在捣鼓node.js,需要一个接口测试工具,而Postman是业界有名的接口测试工具,自然接口测试就用它了. 坑 已经有一段时间没启动Postman了,突然发现启动一直卡在修复界面,重启也不 ...

  5. Fuzz技术综述与文件Fuzz

    文章一开始发表在微信公众号 https://mp.weixin.qq.com/s?__biz=MzUyNzc4Mzk3MQ==&mid=2247486189&idx=1&sn= ...

  6. 使用TOPIAM 轻松搞定「JumpServer」单点登录

    本文将介绍 TOPIAM 与 JumpServer 集成步骤详细指南. 应用简介 JumpServer 是广受欢迎的开源堡垒机,是符合 4A 规范的专业运维安全审计系统.JumpServer 帮助企业 ...

  7. 【Amadeus原创】更改docker run启动参数

    经过一整天的摸索,答案: 没法直接修改.只能另外创建. 但是还好不用完全重头来,用docker commit命令可以基于当前修改的内容创建一个新的image. 执行docker 看看帮助先: Comm ...

  8. 你应该了解的hooks式接口编程 - useSWR

    什么是 useSWR ? 听名字我们都知道是一个 React 的 hooks,SWR 是stale-while-revalidate的缩写, stale 的意思是陈旧的, revalidate 的意思 ...

  9. rabbitmq3.7.3 发布了一个新的 exchange x-random

    direct exchange 同一个 routing key 可以绑定多个 queue,当给这个routing key发消息时,所有 queue 都会投递.这个行为对于一些场景不适用,有时我们希望只 ...

  10. PG 实现 Dynamic SQL

    CREATE OR REPLACE FUNCTION public.exec( text) RETURNS SETOF RECORD LANGUAGE 'plpgsql' AS $BODY$ BEGI ...