第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 使用格式化字符串来实现自定义格式化

复合格式字符串的格式项还可以指定以下内容:

  • 对齐方式:用数值指定最小字宽,正值表示对齐,负值表示对齐
  • 格式化串:常用于日期/时间和数字的表示

image

对齐方式和格式化串是互相独立的可选项。可以指定任意一个,也可以都指定或都不指定。号用于指示对齐方式,号用于指示格式化串。

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 关于内插字符串字面量的硬性限制

内插字符串也有不灵活的地方。最常见的有:

  1. 无法实现动态格式化

    以如下代码为例,它无法动态设定对齐值(代码中设置了固定的对齐值“9”):

    Console.WriteLine($"Price: {price,9:C}");
    

    当然也有折衷办法,使用 WriteLine()​ 的重载方法:

    int alignment = GetAlignmentFromValues(allTheValues);
    Console.WriteLine($"Price: {{0,{alignment}:C}}", price);
    

    可以看到,这样的代码很丑。

  2. 没有表达式的重复计算

    编译器对内插字符串字面量进行转换,转换后的代码会立即执行格式项中的表达式运算。这些表达式的运算不能被延迟,也不能重复执行。

    以如下代码为例,它两次输出的结果 同:

    string value = "Before";
    FormattableString formattable = $"Current value: {value}";
    Console.WriteLine(formattable);
    
    value = "After";
    Console.WriteLine(formattable);
    
  3. 不能出现纯冒号

    表达式种的冒号会被当做表达式和格式串之间的分隔符,导致编译报错。例如:

    Console.WriteLine($"Adult? {age >= 18 ? "Yes" : "No"}");
    

    不过可以通过添加 小括 号解决该问题:

    Console.WriteLine($"Adult? {(age >= 18 ? "Yes" : "No")}");
    
  4. 格式串必须是编译时的已知量

9.4.3 何时可以用但不应该用

Info

另可见C# 中不适用于字符串插值的情形

内插字符串有时会适得其反,主要在两方面:

  1. 无意义的消耗 内存

    有时我们的格式化字符串并不会用到,但是内插字符串会提前创建 string​/FormattableString​ 实例,导致内存消耗:

    Preconditions.CheckArgument(start.Year < 2000,
        Invariant($"Start date {start:yyyy-MM-dd} should not be earlier than year 2000."));
    

    这在日志系统十分常见。

  2. 较长 的表达式使其可读性变差

    内插字符串可读性强的前提是:表达式简短清晰。如下代码表达式十分复杂,使用内插字符串可读性大打折扣:

    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​ 常用于如下几个场景:

  1. 参数校验

  2. 对计算得到的属性设置属性变化通知

    常见于 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) { ... }
    }
    
  3. 在 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> ​ 代替。如下代码对应的值为“ SingleNullable ”:

nameof(Single)
nameof(Nullable<Guid>)

9.5.3.5 名称,简单名称,唯一名称

nameof​ 返回的总是简单名称。如下代码返回的结果都是“ Guid ”:

nameof(Guid)
nameof(System.Guid)

9.5.3.6 命名空间

nameof 可以用于命名空间。但是因为 nameof​ 只返回简单名称,所以该运算符对于命名空间来说作用并不大。

Tips

命名空间也属于成员:命名空间是其他命名空间的成员。

posted @ 2025-04-08 22:58  hihaojie  阅读(53)  评论(0)    收藏  举报