Effective-CSharp-4用内插字符串取代string.Format

自从有了编程这门职业,开发者需要把计算机中板寸的信息转换成更便于人阅读的格式。C# 语言中的相关 API 可以追溯到几十年前诞生的 C 语言,但是这些旧习惯应该改变,C# 6.0 提供了内插字符串 (Interpolated String) 这项新功能可以更好的用来设置字符串格式。

与旧习惯相比,这项功能有很多好处。开发者可用他们写出可读性更高的代码,编译器也可以用它实现更为完备的静态类型检查机制,降低程序出错概率。此外,它还可以提供更加丰富的语法,令你可以用更为合适的表达式来生成自己想要的字符串的格式。

String.Format() 虽然可使用,但是会导致一些问题。

  1. 开发者必须对生成的字符串进行测试及验证。所有的替换操作都是根据格式字符串中的序号来完成的,而编译器不会验证格式字符串后的参数个数与有待替换的序号数是否相等。如果两者不相等,那么程序在运行时将抛出异常。
  2. 格式字符串中的序号与 params 数组中的位置必须对应。而阅读代码的人不太容易看出数组中的字符串是不是按照正确顺序排列。必须允许代码,并检查生成的字符串,才能确认这一点。

不妨用 C# 提供的新特性简化代码编写工作,这项新特性就是内插字符串,这种语法糖 (syntactic sugar),的功能是极其强大的。

内插字符串以 $ 开头,不像传统的格式字符串把序号放在一对花括号,并用其指代的 params 数组中的对应元素,而是直接在花括号中编写 C# 表达式。

  1. 开发者能直接在字符串中看到待替换的内容,代码可读性高
  2. 表达式在字符串内,每一个有待替换的部分都能与替换部分的表达式对应

花括号中的内容叫作表达式而不是泛称为语句,其不能使用 if / else 或 while 等控制流语句来做替换。若需要控制流语句做替换,那么必须把这些逻辑写成方法,在内插字符串里嵌入该方法的调用结果。

字符串内插机制是通过库代码完成的,那些代码与当前的 string.Format() 类似 (至于如何实现国际化,见第五条)。内插字符串会在必要的时候把变量从其他类型转换为 string 类型:

1
Console.WriteLine($"The value of pi is {Math.PI}");

由字符串内插操作生成的代码会调用一个参数为 params 对象数组的格式化方法。 Math.PI 是 double 类型,而 double 类型是值类型,因此,必须将其自动转换为 Object 才行。此转换需装箱,若此代码运行很频繁,或需要在短小的循环中反复执行,那么会严重影响性能 (见第9条)。该情况下,开发者应自己去做字符串转换,这样就不需要给表达式中的数值装箱了:

1
Console.WriteLine($"The value of pi is {Math.PI.ToString()}");

如果 ToString() 直接返回的文本不符合你的要求,那么可以修改其他参数,创造你想要的文本:

1
Console.WriteLine($"The value of pi is {Math.PI.ToString("F2")}")

可使用标准格式说明符 (C# 语言内建说明符) 来调整字符串格式。我们有可能需要对字符串做一些处理,或是把表达式返回的对象格式化,只需要在大括号中的表达式后面加上冒号,并将格式说明符写在右侧。

1
Console.WriteLine($"The value of pi is {Math.PI:F2}");

由于条件表达式也使用冒号,因此,如果在内插字符串中用冒号,那么 C# 可能会把他理解成格式说明符的前导符,而不将其视为条件表达式的一部分,这行代码是无法编译的:

1
Console.WriteLine($"The value of pi is {round ? Math.PI.ToString(): Math.PI.ToString("F2")}");

可以用小括号将整个内容括起,编译器就不会再把冒号是为格式字符串的前导符了。

1
Console.WriteLine($"The value of pi is {(round ? Math.PI.ToString() : Math.PI.ToString("F2"))}");

可以通过 null 合并运算符 (null-coalescing operator) 与 null 条件运算符 (null-conditional operator,也称为 null-propagation operator (null 传播运算符)) 更清晰的处理那些可能缺失的值:

1
Console.WriteLine($"The customer's name is {c?.name ?? "Name is missing"}");

通过示例可以看出,花括号中还可以嵌入字符串,凡是位于 { 和 } 之间的字符,就都会被当作此表达式中的 C# 代码,并加以解析 (冒号除外,他是用来表示右侧的内容是格式说明符)。

内插字符串可以嵌套。合理利用此方法,可以极大的简化编程工作量。

1
2
3
4
5
Console.WriteLine(
$@"Record is {(
records.TryGetValue(index, out string result) ? result : $"No record found at index {index}"
)}"
);

如果查找的记录不存在,那么就会执行条件表达式的 false 部分,从而令嵌套的内插字符串生效,该字符串会返回一条消息,指出要查找位置的记录不存在。

可以使用 LINQ 查询操作来创建内容,其本身也支持利用内插字符串调整查询结果格式

1
2
3
4
5
6
7
8
9
var output = $@"The First five items are: {
src.Take(
5
).Select(
n => $@"Item: {n.ToString()}"
).Aggregate(
(c, a) => $@"{Environment.NewLine}{a}"
)
}";

上面的这种写法可能不太会出现在正式的产品代码中,但可以看出,内插字符串和 C# 之间结合的相当密切。ASP.NET MVC 框架中的 Razor View 引擎也支持内插字符串,使得开发者在编写 Web 应用程序时能够更便捷地以 HTML 的形式来输出信息。默认的 MVC 应用程序本身就演示了怎样在 Razor View 中使用内插字符串,以下示例节选自 controller 部分,它可以显示当前登入的用户名:

1
<a asp-controller="Message" asp-action="Index" title="Manage"> Hello@User.GetUserName()!</a>

构建应用程序中的其他 HTML 页面时,也可以采用这个技巧,更为精确地表达你想输出的内容。

上述实例展示了内插字符串的强大功能,虽然这些功能可用传统格式化字符串实现,但是比较麻烦。值得注意的地方在于,内插字符串本身也会解析称为一条普通的字符串 (将其中的填充部分解析填充后,其与普通字符串无差别)。使用内插字符串创建 SQL 命令是极其危险的:内插字符串不会创建参数化的 SQL 查询 (parameterized SQL query),只会形成一个普通的 string 对象,参数已经全部被写入至该 string 中了。不只是 SQL 命令,凡是需要留到运行时去解析的信息都有此风险,开发者需要特别小心。