第9章 字符串特性
第9章 字符串特性
9.1 .NET 中的字符串格式化回顾
Info
相关内容请参考第6章 框架基础
9.1.1 简单字符串格式化
下面是使用格式化字符串的一个简单例子:
// C#5 的旧格式化代码
Console.Write("What's your name? ");
string name = Console.ReadLine();
Console.WriteLine("Hello, {0}!", name);
Console.WriteLine()、string.Format() 等 API 都应用了该模式:方法接收一个复合格式串,包括格式项以及对应的实参。
9.1.2 使用格式化字符串来实现自定义格式化
复合格式字符串的格式项还可以指定以下内容:
- 对齐方式:用数值指定最小字宽,正值表示右对齐,负值表示左对齐
- 格式化串:常用于日期/时间和数字的表示
对齐方式和格式化串是互相独立的可选项。可以指定任意一个,也可以都指定或都不指定。逗号用于指示对齐方式,冒号用于指示格式化串。
decimal price = 95.25m;
decimal tip = price * 0.2m;
Console.WriteLine("Price: {0,9:C}", price);
Console.WriteLine("Tip: {0,9:C}", tip);
Console.WriteLine("Total: {0,9:C}", price + tip);
9.1.3 属地化(国际化)
.NET 提供了 CultureInfo 类进行属地化工作。该类型实现了 IFormatProvider 接口,大部分格式化方法会有一个重载方法,IFormatProvider 是重载方法的首个形参。以 string.Format() 为例:
static string Format(IFormatProvider provider, string format, params object[] args)
static string Format(string format, params object[] args)
虽然形参的类型是 IFormatProvider,但传入的实参基本上总是 CultureInfo。如下代码演示了按照 US 英语格式化日期:
var usEnglish = CultureInfo.GetCultureInfo("en-US");
var birthDate = new DateTime(1976, 6, 19);
string formatted = string.Format(usEnglish, "Jon was born on {0:d}", birthDate);
其他 culture 会采用完全不同的格式。如下代码使用.NET 支持的所有 culture 打印同一日期:
var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
var birthDate = new DateTime(1976, 6, 19);
foreach (var culture in cultures)
{
string text = string.Format(
culture, "{0,-15} {1,12:d}", culture.Name, birthDate);
Console.WriteLine(text);
}
9.1.3.1 根据默认 culture 格式化
如果没有指定 format provider,或者给 IFormatProvider 参数传递了 null 值,CultureInfo.CurrentCulture 就会被设置为默认值。默认值取决于当前所在上下文,可以分别设置每个线程的默认值,而有些 Web 框架会在特定线程处理请求之前进行设置。
9.1.3.2 为机器提供格式化
前面所讲的格式化内容都是提供给终端用户的,对于机器-机器的通信(例如某个 Web 服务进行 URL 请求的参数解析),推荐使用文化无关的 CultureInfo:CultureInfo.InvariantCulture。
Suggest
多数时候不需要直接为机器−机器的通信直接格式化数据,并尽量避免字符串转换。如果使用了字符串转换,往往预示着代码没有合理使用库或框架,或者存在数据设计问题(例如在数据库中用文本来保存日期,而不是本地的日期/时间类型)。
9.2 内插字符串字面量介绍
9.2.1 简单内插
该语法以 $ 符号开头,位于双引号前。编译器可以根据$ 符号判断当前字符串是内插字符串而不是普通字符串。新语法中的格式项使用 {value} 而不是 {0}。大括号内的文本是一个表达式,该表达式运算后的结果用于字符串的格式化。
试比较如下两段代码:
// C#5 的旧格式化代码 Console.Write("What's your name? "); string name = Console.ReadLine(); Console.WriteLine("Hello, {0}!", name);
// C#6 的内插字符串字面量
Console.Write("What's your name? ");
string name = Console.ReadLine();
Console.WriteLine($"Hello, {name}!");
9.2.2 使用内插字符串字面量格式化字符串
内插方式的对齐和格式串与之前的代码差别不大:
- 对齐方式:使用逗号分隔
- 格式串:使用分号分隔
试比较如下两段代码:
decimal price = 95.25m; decimal tip = price * 0.2m; Console.WriteLine("Price: {0,9:C}", price); Console.WriteLine("Tip: {0,9:C}", tip); Console.WriteLine("Total: {0,9:C}", price + tip);
decimal price = 95.25m;
decimal tip = price * 0.2m;
Console.WriteLine($"Price: {price,9:C}");
Console.WriteLine($"Tip: {tip,9:C}");
Console.WriteLine($"Total: {price + tip,9:C}");
请注意最后一行代码:内插字符串不是只包含一个实参值,它把“价格”和“小费”进行了相加。这个表达式可以是任何能够计算值的表达式
9.2.3 内插原义字符串字面量
原义字符串字面量也可以采用内插语法:只需像普通字符串字面量那样,将 $ 符号置于 @ 符号前即可。前面多行输出的例子改为使用原义字符串,则有:
decimal price = 95.25m; decimal tip = price * 0.2m; Console.WriteLine("Price: {0,9:C}", price); Console.WriteLine("Tip: {0,9:C}", tip); Console.WriteLine("Total: {0,9:C}", price + tip);
decimal price = 95.25m;
decimal tip = price * 0.2m;
Console.WriteLine($@"Price: {price,9:C}
Tip: {tip,9:C}
Total: {price + tip,9:C}");
Tips
上面只是简单举例,它不如把语句拆分更整洁。
$ 和 @ 符号的顺序很重要。我个人没有特别好的记忆方法,大家就按照自己的方法编写,如果编译器报错就调换顺序。
9.2.4 编译器对内插字符串字面量的处理(第 1 部分)
编译器对内插字符串字面量做的转换比较简单。它把内插字符串字面量转换成 string.Format() 方法调用,并且把格式串中的表达式抽取出来,作为 string.Format() 方法调用的实参放在复合格式串之后。原先表达式的位置则被替换成对应的索引值。
以如下代码为例,编译器将左侧代码转为右侧代码:
int x = 10;
int y = 20;
string text = $"x={x}, y={y}";
Console.WriteLine(text);
int x = 10;
int y = 20;
string text = string.Format("x={0}, y={1}", x, y);
Console.WriteLine(text);
截至目前,所有内插字符串都会转换成 string.Format() 方法调用,但偶尔也有例外,参见9.3.1 编译器对内插字符串字面量的处理(第 2 部分)。
9.3 使用 FormattableString 实现属地化
对于内插字符串,我们可以提供对齐方式、格式串,但是无法传入 CultureInfo。为此 .NET 4.6(以及 .NET Standard 1.3)引入了 FormattableString 类型。它能够保存当前复合格式串和值的信息,待获取 culture 信息后,再进行格式化。
下面是一个简单的用例:
var dateOfBirth = new DateTime(1976, 6, 19);
FormattableString formattableString = $"Jon was born on {dateOfBirth:d}";
var culture = CultureInfo.GetCultureInfo("en-US");
var result = formattableString.ToString(culture);
编译器可以识别该类型,会把内插字符串字面量转换成 FormattableString 。
9.3.1 编译器对内插字符串字面量的处理(第 2 部分)
在内插字符串赋值给 FormattableString 时,编译器进行了额外的处理。它类似于 var x = 5 是合法的,x 最终是 int 类型,而 byte y = 5,理论上 y 只能是 int 类型,但它也是合法的(语言规定),因为编译器自动对数据进行了处理。
前面提到,对于内插字符串字面量转为 string 时,我们有:
编译器对内插字符串字面量做的转换比较简单。它把内插字符串字面量转换成
string.Format() 方法调用,并且把格式串中的表达式抽取出来,作为 string.Format() 方法调用的实参放在复合格式串之后。原先表达式的位置则被替换成对应的索引值。
对于 FormattableString,它调用的是 System.Runtime.CompilerServices.FormattableStringFactory.Create() 静态方法。下面两段代码等价:
int x = 10;
int y = 20;
FormattableString formattable = $"x={x}, y={y}";
int x = 10;
int y = 20;
FormattableString formattable = FormattableStringFactory.Create("x={0}, y={1}", x, y);
9.3.2 在特定 culture 下格式化一个 FormattableString
在实际编码中,我们最常使用的文化是 invariant culture。通过 FormattableString 的 ToString() 方法传入 CultureInfo.InvariantCulture 很是繁琐。以如下代码为例:
DateTime date = DateTime.UtcNow;
string parameter2 = ((FormattableString)$"x={date:yyyy-MM-dd}").ToString(CultureInfo.InvariantCulture);
为此,FormattableString 提供了静态方法 Invariant() ,它默认使用 CultureInfo.InvariantCulture 文化信息。上述代码可以简化成如下形式:
DateTime date = DateTime.UtcNow;
string parameter3 = FormattableString.Invariant($"x={date:yyyy-MM-dd}");
9.3.2.1 在非 invariant culture 下完成格式化
因 FormattableString 实现了 IFormattable 接口,FormattableString 实例可以传递给任何接受 IFormattable 参数的代码,例如:
void Log(IFormattable message) {
Console.WriteLine(message.ToString(null, CultureInfo.InvariantCulture));
}
// 使用字符串插值(隐式转换为 FormattableString)
Log($"Current time: {DateTime.Now}");
这使得 FormattableString 为与其他实现了 IFormattable 接口的类型(如 int、double)兼容。而 FormattableString 的实现中,ToString(string format, IFormatProvider formatProvider) 方法会忽略 ** format ** 参数,因为它已经通过插值字符串捕获了完整的格式信息。例如:
FormattableString str = $"Price: {price:C2}";
// 这里的 "C2" 已经是插值字符串的格式部分
它可以用于如下需要支持多语言格式化的场景:
// 接收 IFormattable 参数,根据用户区域设置格式化
string FormatForUser(IFormattable message, CultureInfo userCulture) {
return message.ToString(null, userCulture);
}
// 使用 FormattableString 传递插值字符串
var price = 123.45m;
FormattableString message = $"Total: {price:C2}";
// 根据用户文化输出不同格式
Console.WriteLine(FormatForUser(message, CultureInfo.GetCultureInfo("en-US"))); // "Total: $123.45"
Console.WriteLine(FormatForUser(message, CultureInfo.GetCultureInfo("de-DE"))); // "Total: 123,45 €"
此处,FormattableString 的 IFormattable 实现允许直接传递插值字符串,并依赖其自身存储的格式(C2),但根据传入的 formatProvider 调整货币符号和小数分隔符。
9.3.3 FormattableString 的其他用途
本节以 SQL 注入攻击为例,使用 FormattableString 规避该风险。暂时略过。#delay#
9.3.4 在旧版本 .NET 中使用 FormattableString
与7.2.5 旧版本 .NET 使用调用方信息 attribute相似,可以自定义 FormattableString 以在旧版本 .NET 中使用。
Tips
C#编译器并不限定哪个程序集应当包含它依赖的
FormattableString 和FormattableStringFactory 类型。编译器在意的是命名空间,以及FormattableStringFactory 是否包含合适的静态Create() 方法,仅此而已。
9.4 使用指南和使用限制
9.4.2 关于内插字符串字面量的硬性限制
内插字符串也有不灵活的地方。最常见的有:
-
无法实现动态格式化
以如下代码为例,它无法动态设定对齐值(代码中设置了固定的对齐值“9”):
Console.WriteLine($"Price: {price,9:C}");当然也有折衷办法,使用
WriteLine() 的重载方法:int alignment = GetAlignmentFromValues(allTheValues); Console.WriteLine($"Price: {{0,{alignment}:C}}", price);可以看到,这样的代码很丑。
-
没有表达式的重复计算
编译器对内插字符串字面量进行转换,转换后的代码会立即执行格式项中的表达式运算。这些表达式的运算不能被延迟,也不能重复执行。
以如下代码为例,它两次输出的结果 相 同:
string value = "Before"; FormattableString formattable = $"Current value: {value}"; Console.WriteLine(formattable); value = "After"; Console.WriteLine(formattable); -
不能出现纯冒号
表达式种的冒号会被当做表达式和格式串之间的分隔符,导致编译报错。例如:
Console.WriteLine($"Adult? {age >= 18 ? "Yes" : "No"}");不过可以通过添加 小括 号解决该问题:
Console.WriteLine($"Adult? {(age >= 18 ? "Yes" : "No")}"); -
格式串必须是编译时的已知量
9.4.3 何时可以用但不应该用
Info
另可见C# 中不适用于字符串插值的情形
内插字符串有时会适得其反,主要在两方面:
-
无意义的消耗 内存
有时我们的格式化字符串并不会用到,但是内插字符串会提前创建
string/FormattableString 实例,导致内存消耗:Preconditions.CheckArgument(start.Year < 2000, Invariant($"Start date {start:yyyy-MM-dd} should not be earlier than year 2000."));这在日志系统十分常见。
-
较长 的表达式使其可读性变差
内插字符串可读性强的前提是:表达式简短清晰。如下代码表达式十分复杂,使用内插字符串可读性大打折扣:
private static string FormatMemberDebugName(MemberInfo m) => string.Format("{0}.{1}({2})", m.DeclaringType.Name, m.Name, string.Join(", ", GetParameters(m).Select(p => p.ParameterType)));
9.5 使用 nameof 访问标识符
9.5.2 nameof 的一般用法
nameof 常用于如下几个场景:
-
参数校验
-
对计算得到的属性设置属性变化通知
常见于 WPF:
public class Rectangle : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; private double width; private double height; public double Width { get { return width; } set { if (width == value) { return; } width = value; RaisePropertyChanged(); RaisePropertyChanged(nameof(Area)); } } public double Height { ... } public double Area => Width * Height; private void RaisePropertyChanged( [CallerMemberName] string propertyName = null) { ... } } -
在 attribute 中使用
有时 attribute 可以指代其他成员,用于指示成员之间的关系。如下代码演示了在 Entity Framework 中,attribute 指示外键是哪个属性:
public class Employee { [ForeignKey(nameof(Employer))] public Guid EmployerId { get; set; } public Company Employer { get; set; } }
9.5.3 使用 nameof 的技巧与陷阱
9.5.3.1 指向其他类型的成员
nameof 可以指向其他类型的成员。在指向其他类型成员时,应使用通过名引入,而非类型实例。
Console.WriteLine(nameof(instance.InstanceMember));
// 下面的书写方式更好
Console.WriteLine(nameof(OtherClass.InstanceMember));
Info
推荐使用** 类型名 而非 实例字段 **,可以保证代码的一致性。
9.5.3.2 泛型
使用 nameof 时,必须指定 类型实参 ,但结果中不会包含该 类型实参 ,也不会体现类型形参的个数。如下代码均输出“ Action ”
Console.WriteLine(nameof(Action<string>));
Console.WriteLine(nameof(Action<string, string>));
此外,nameof 的值编译时确定。如下反复顺出的结果均是“ T ”:
Console.WriteLine(Method<Guid>());
Console.WriteLine(Method<Button>());
static string Method<T>() => nameof(T);
9.5.3.3 使用别名
对于使用了别名的成员,nameof 会按照别名输出。如下代码会输出“ GuidAlias ”:
using System;
using GuidAlias = System.Guid;
Console.WriteLine(nameof(GuidAlias));
9.5.3.4 预定义别名、数组和可空值类型
nameof 运算符不能和预定义的别名(比如 int、char、long 等)搭配使用,不能和可空值类型(带 ? 后缀的类型)搭配,也不能和数组类型搭配,因此以下调用均 非 法:
nameof(float)
nameof(Guid?)
nameof(String[])
有一些折中的办法可以使用 nameof。预定义别名可以使用它的 CLR 类型名;可空值类型可以使用 Nullable<T> 代替。如下代码对应的值为“ Single 、 Nullable ”:
nameof(Single)
nameof(Nullable<Guid>)
9.5.3.5 名称,简单名称,唯一名称
nameof 返回的总是简单名称。如下代码返回的结果都是“ Guid ”:
nameof(Guid)
nameof(System.Guid)
9.5.3.6 命名空间
nameof 也 可以用于命名空间。但是因为 nameof 只返回简单名称,所以该运算符对于命名空间来说作用并不大。
Tips
命名空间也属于成员:命名空间是其他命名空间的成员。

浙公网安备 33010602011771号