AIGC标识 [深入解析C#] 第9章:字符串特性

🍀 定位:了解C#发展史和底层原理,而非现代的规范指南

注意:

书中最新提到的C#版本是C#8,而现在 2026年3月21日 15:49 是C#14,需要注意对比新特性和新语法

笔记仅作为要点和思路记录,复盘时可借助AI扩充或原书参考

🍀 同样📦作为了解的内容,❗️作为重要的内容

第 9 章——字符串特性

9.1 .NET中的字符串格式化回顾

9.1.1 简单字符串格式化(string.Format / Console.WriteLine 复合格式)

  • 核心概念:在 C# 6 之前,使用复合格式字符串("{0}, {1}")配合索引占位符进行字符串格式化,是字符串拼接的主要方式之一。

  • 关键点

    • 复合格式字符串:格式字符串中包含用大括号 {} 包裹的数字索引(从 0 开始),运行时替换为对应位置的实参。
    • 常见使用 APIConsole.WriteLinestring.FormatStringBuilder.AppendFormat 等,均支持该模式。
    • 索引必须与实参顺序对应{0} 代表第一个实参,{1} 代表第二个,以此类推;参数数量和索引不匹配会引发运行时异常。
    • 局限性:参数较多时索引难以维护,阅读时需要对照参数列表,容易错位;无法直接嵌在字符串字面量中看到实际内容。
    • 后续铺垫:C# 6 引入的字符串内插($"Hello, {name}!")解决了上述可读性和维护性问题。
  • 代码示例

    Console.Write("What's your name? ");
    string name = Console.ReadLine();
    Console.WriteLine("Hello, {0}!", name);   // {0} 替换为 name
    
    // 多个占位符
    int x = 5, y = 10;
    string result = string.Format("Point: ({0}, {1})", x, y); // "Point: (5, 10)"
    
  • 面试准备建议

    • 重要性:⭐⭐(基础,理解演进动机即可)
    • 面试回答要点
      • 旧式字符串格式化为 string.Format("模式", 参数列表),用 {索引} 占位。
      • 缺陷:参数多时难以阅读,索引容易写错,无法直观看出最终字符串形状。
      • C# 6 字符串内插可直观在字符串中嵌入表达式,极大提升可读性且编译期检查。面试时可对比说明。

9.1.2 自定义格式化字符串(对齐与格式串)

  • 核心概念:复合格式项 {索引,对齐:格式串} 支持控制最小宽度、对齐方式以及类型特定的格式化字符串,实现对齐和可读的输出。

  • 关键点

    • 格式项完整语法{索引,对齐:格式串}
      • 索引:实参位置(从 0 开始)。
      • 对齐(可选):带符号整数。正数右对齐,负数左对齐;数字表示最小字符宽度,不足时补空格,超过时不受影响。
      • 格式串(可选):由冒号引导,含义取决于被格式化值的类型(如日期 yyyy-MM-dd,货币 C,数字 N2 等)。
    • 逗号和冒号的规则:逗号用于引入对齐,冒号用于引入格式串;格式串内可以出现逗号。
    • 对齐行为的本质:仅指定最小宽度,并非固定宽度。若需适应任意大值,需预先估算最大宽度,无内置自动对齐方案。
    • 文化依赖:格式化结果受当前区域性(CultureInfo)影响,例如货币符号、小数点分隔符、数字分组等。示例代码在 US English 下输出 $95.25,在其他文化下会不同。
    • 常见应用:输出表格化的账单、日志对齐等场景。
  • 代码示例

    decimal price = 95.25m;
    decimal tip = price * 0.2m;
    // 右对齐,最小宽度 9,货币格式
    Console.WriteLine("Price: {0,9:C}", price);
    Console.WriteLine("Tip:   {0,9:C}", tip);
    Console.WriteLine("Total: {0,9:C}", price + tip);
    // 输出示例(US English):
    // Price:    $95.25
    // Tip:      $19.05
    // Total:   $114.30
    
  • 面试准备建议

    • 重要性:⭐⭐(实用基础,格式化输出常见)
    • 面试回答要点
      • 能说出格式项的三个部分:索引、对齐(可选)、格式串(可选),并解释正负对齐的左右对齐效果。
      • 知道格式串依赖于类型,如 DateTime"yyyy-MM-dd",数字用 "C"(货币)、"N2"(数字带两位小数)等。
      • 强调对齐只规定最小宽度,非固定宽度;若实际值更长,对齐会“失效”。
      • 可提及文化差异性:格式化结果会随 CultureInfo 改变,如需固定格式(如机器可读),应使用 CultureInfo.InvariantCulture
      • 面试中若问及字符串内插(C# 6),可对比说明在内插字符串中同样支持对齐和格式串:$"{price,9:C}",语法一致且可读性更好。

9.1.3 属地化与格式化提供程序(IFormatProvider / CultureInfo)

  • 核心概念:.NET 使用 CultureInfo(实现 IFormatProvider)控制字符串格式化的语言/区域偏好,包括数字、日期、货币格式等。格式化方法通常提供接受 IFormatProvider 的重载。

  • 关键点

    • CultureInfo 的作用
      • 决定分隔符(小数点 .,)、货币符号、日期顺序等。
      • 影响排序规则、星期/月份的名称翻译。
    • 格式化方法的重载模式
      • IFormatProvider 作为第一个参数的重载(如 string.Format(IFormatProvider, string, params object[]))。
      • 原因:params 数组必须是最后一个参数,所以 provider 前置。
    • 默认行为:未显式指定时,使用 CultureInfo.CurrentCulture(线程相关),需注意多线程环境下的正确性。
    • 为用户格式化:应显式传入用户所在地的 CultureInfo,而不是依赖默认值。
    • 为机器格式化(如 URL 参数、序列化):必须使用 CultureInfo.InvariantCulture,保证格式固定、不随环境变化。
    • 避免不必要的字符串转换:如果数据要用于机器间通信,优先使用强类型传递而非字符串拼接;必须拼接时才选择合适的 culture。
    • 格式化串的对齐和格式串依然有效:如 {0,-15}(左对齐 15 字符宽度)、{1,12:d}(右对齐 12 字符宽度,短日期格式)。
  • 代码示例

    // 使用特定 culture 格式化
    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);
    // en-US: 6/19/1976  (月/日/年)
    // en-GB: 19/06/1976 (日/月/年,月份补零)
    
    // 遍历所有 culture
    foreach (var culture in CultureInfo.GetCultures(CultureTypes.AllCultures))
        Console.WriteLine("{0,-15} {1,12:d}", culture.Name, birthDate);
    
    // 机器通信使用 InvariantCulture
    string url = string.Format(CultureInfo.InvariantCulture,
        "{0}?date={1:yyyy-MM-dd}", baseUrl, searchDate);
    
  • 面试准备建议

    • 重要性:⭐⭐(中等,基础概念,用于本地化场景)
    • 面试回答要点
      • CultureInfo 实现 IFormatProvider,控制数字、日期等格式化规则;CurrentCulture 是默认值,InvariantCulture 用于机器间通信。
      • 格式化时显式指定 CultureInfo 可避免多线程或环境差异引起的问题。
      • 对于需要保证格式不变的场景(如日志、网络协议、文件存储)应使用 CultureInfo.InvariantCulture
      • 若问及字符串内插(C# 6),可补充内插字符串也支持通过 IFormatProvider 控制文化,如 $"{value:C}" 默认用 CurrentCulture,如需固定文化可调用 FormattableString.InvariantToString(provider)
      • Unity 中本地化(Localization)通常由专门的包或系统处理,但理解底层格式化机制有助于正确处理文本显示和资源序列化。

9.2 内插字符串字面量介绍

❗️9.2.1 内插字符串字面量介绍(C# 6)

  • 核心概念:以 $ 为前缀的字符串字面量,允许直接在字符串大括号内嵌入表达式,取代 string.Format 的索引占位符,提升可读性和编码效率。

  • 关键点

    • 语法$"字符串内容 {表达式}"$ 位于双引号前,编译器将其识别为内插字符串。
    • 与旧式对比
      • 旧式:"Hello, {0}!" + 参数列表,索引与实参分离。
      • 新式:$"Hello, {name}!" ,表达式直接嵌入,所见即所得。
    • 编译行为:编译器将内插字符串转换为对 string.Format 的调用(或生成 FormattableString 对象,见下文),效果等价,但代码更清晰。
    • 简单用法:直接在大括号内写入表达式(变量、属性、方法调用等),结果自动调用 ToString
    • 可读性优势:尤其在多个格式项时,无需来回对照索引和参数列表,减少错误。
    • 可选格式化:大括号内仍可指定对齐和格式串,如 $"{value,9:C}" ,语法与 string.Format 完全兼容。
    • 后续扩展:本节后续将介绍通过 FormattableString 实现属地化、编译器处理细节、常见场景及局限性。
  • 代码示例

    // 旧式 (C# 5)
    Console.WriteLine("Hello, {0}!", name);
    
    // 内插字符串 (C# 6)
    Console.WriteLine($"Hello, {name}!");
    
    // 带格式
    decimal price = 95.25m;
    Console.WriteLine($"Price: {price,9:C}"); // 右对齐,货币格式
    
  • 面试准备建议

    • 重要性:⭐⭐⭐(高频使用,必须掌握)
    • 面试回答要点
      • 内插字符串是 C# 6 的语法糖,使用 $ 前缀,大括号内可直接写表达式,消除了位置索引导致的阅读困难。
      • 本质会被编译器转换为 string.Format 调用,因此性能与旧式相同。
      • 支持对齐和格式串:$"{value,10:F2}",与旧式完全兼容。
      • 面试时若被问及字符串拼接优化,可以指出内插字符串比 + 拼接更清晰,且不会产生额外的中间字符串(内部使用 StringBuilder 类似机制),但大量拼接时仍需考虑使用 StringBuilder
      • 可顺便提及 FormattableString 允许拦截内插表达式,用于自定义格式化或文化敏感场景。

9.2.2 内插字符串的格式化与对齐

  • 核心概念:内插字符串完全兼容旧式 string.Format 的对齐和格式串语法,直接在表达式后使用 ,对齐:格式串 即可,表达式本身也可以是复杂计算。

  • 关键点

    • 对齐与格式串语法不变
      • $"{表达式,对齐:格式串}",逗号后为最小宽度及对齐方向,冒号后为格式说明符。
      • {price,9:C}:右对齐占9个字符,货币格式。
    • 表达式可以是任意返回值表达式:包括算术运算、方法调用等,只要不是 void
    • 格式化底层
      • 若表达式结果实现了 IFormattable,调用 ToString(string, IFormatProvider)
      • 否则调用 ToString()
    • 可读性:将值与格式紧密结合,省去索引对应,代码一目了然。
  • 代码示例

    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}"); // 直接计算表达式
    
  • 面试准备建议

    • 重要性:⭐⭐(使用频率高,理解即可)
    • 面试回答要点
      • 内插字符串同时支持对齐和格式串,语法与 string.Format 完全相同,如 {value,10:F2}
      • 大括号内可以是任何 C# 表达式(非 void),编译时转换为 string.Format 调用,因此没有额外性能开销。
      • 可以借此特性在日志或 UI 中快速构建对齐、格式化的文本,例如在 Unity 中显示调试信息或格式化 UI 文本。
      • 面试官如果问“内插字符串和 string.Format 有何关系”,可答“是语法糖,最终调用 string.Format,但更易读、避免索引错位”。

9.2.3 内插原义字符串字面量(C# 6)

  • 核心概念:将内插字符串($)与原义字符串(@)结合,使用 $@"..." 语法,使字符串既支持直接嵌入表达式,又保留原义字符串的反斜杠原样、换行保留等特性。

  • 关键点

    • 原义字符串@"...")特点:
      • 反斜杠 \ 不再表示转义,直接作为字符。
      • 换行符直接保留在字符串中,无需 \n
      • 唯一的转义:两个双引号 "" 表示一个双引号字符。
      • 常用于:多行文本、Windows 文件路径、正则表达式。
    • 组合语法规则
      • $@"..." 合法@$"..." 不合法$ 必须在 @ 之前。
      • 若写错顺序,编译器会报错,调换即可。
    • 使用场景
      • 多行内插文本(如拼接 SQL、多行日志、代码生成模板)。
      • 包含大量反斜杠的路径或正则表达式,同时需要内插变量。
    • 注意:如果仅是简单对齐或格式化,常规内插字符串就足够;不必为用而用内插原义字符串。
  • 代码示例

    // 原义字符串示例
    string sql = @"
        SELECT City, ZipCode
        FROM Address
        WHERE Country = 'US'";
    Regex regex = new Regex(@"[a-z]+\.\d+");
    string path = @"c:\users\skeet\Test\Test.cs";
    
    // 内插原义字符串字面量
    decimal price = 95.25m;
    decimal tip = price * 0.2m;
    Console.WriteLine($@"Price: {price,9:C}
    Tip:   {tip,9:C}
    Total: {price + tip,9:C}");
    
  • 面试准备建议

    • 重要性:⭐⭐(实用语法点,了解即可)
    • 面试回答要点
      • 内插原义字符串字面量是 C# 6 的组合语法:$@"...",兼具变量嵌入和原义保留。
      • 顺序固定:$ 必须在前,@ 在后,否则编译错误。
      • 适用场景:需要在包含反斜杠(路径、正则)或换行(SQL、模板)的字符串中嵌入表达式时。
      • string.Format 的关系:仍为语法糖,编译后转换为相应的 string.Format 调用,性能无差异。
      • 面试可举例:编写跨平台路径时 $@"{root}\Data\{filename}" 比拼接或普通转义更清晰。

9.2.4 编译器如何转换内插字符串(第一部分)

  • 核心概念:编译器将内插字符串字面量转换为对 string.Format 的调用,格式项中的表达式被提取为实参,原位置替换为索引占位符。

  • 关键点

    • 转换规则
      • $"x={x}, y={y}"string.Format("x={0}, y={1}", x, y)
      • 表达式的出现顺序决定索引:第一个表达式变成 {0},第二个变成 {1},以此类推。
    • 非常量表达式
      • 内插字符串不被视为常量表达式(const),即使所有内容在编译期已知。
      • 这是转换方式的副作用,语言设计上认为处理这些极端情况的复杂性高于收益。
    • 验证方法:可使用 ildasm 或其他 IL 反编译工具查看编译器生成的 IL 代码,确认 string.Format 调用。
    • 后续铺垫:这种转换是默认行为,但在特定场景下编译器会产生不同的代码(为 FormattableString 做铺垫)。
  • 代码示例

    int x = 10, y = 20;
    string text = $"x={x}, y={y}";
    // 编译后等价于:
    // string text = string.Format("x={0}, y={1}", x, y);
    
  • 面试准备建议

    • 重要性:⭐⭐(理解原理,有助于解释行为)
    • 面试回答要点
      • 内插字符串是编译时语法糖,默认被转换为 string.Format 调用,表达式变为按顺序索引的实参。
      • 不是常量,因此不能用于 const 声明,即使表达式都是常量字符串也不行。
      • 若被问及性能:内插字符串本身无额外运行时开销,和手动调用 string.Format 相同;若需避免每次执行时解析格式串,可考虑 FormattableString 或缓存格式化结果。
      • 可以补充:内插字符串在特定上下文中会被转换为 FormattableString,这是实现本地化格式化的关键。

9.3 使用 FormattableString 实现属地化

9.3.1 使用 FormattableString 实现属地化与编译器处理(第二部分)

  • 核心概念FormattableString 允许将内插字符串的格式串和值分离保存,延迟到获取 CultureInfo 后再执行格式化,从而实现属地化。编译器在目标类型为 FormattableString(或 IFormattable)时自动生成相应转换代码。

  • 关键点

    • 属地化的三个要素
      • 复合格式串(含硬编码文本与格式占位符)
      • 值本身
      • 格式化所依据的 CultureInfo / IFormatProvider
    • 普通内插字符串的局限:使用当前线程的默认 culture 立即格式化为 string,无法再改变 culture。
    • FormattableString 的解决方案
      • 位于 System 命名空间,.NET 4.6+ / .NET Standard 1.3+ 引入。
      • 保存复合格式串和实参值,暂不格式化。
      • 调用 ToString(IFormatProvider) 传入目标 culture 时才完成格式化。
    • 编译器转换机制(第 2 部分)
      • 当内插字符串被赋值给 FormattableStringIFormattable 时,编译器不调用 string.Format
      • 改为调用 FormattableStringFactory.Create("复合格式串", 实参列表) 生成 FormattableString 实例。
      • 这是表达式到类型的隐式转换,与类型间转换不同(类比 int 字面量 5 可隐式转为 byte)。
    • FormattableString 关键成员
      • Format:获取复合格式串。
      • GetArguments() / GetArgument(int index):获取所有实参或指定索引的实参。
      • ArgumentCount:实参数量。
      • ToString(IFormatProvider):使用指定 provider 格式化。
      • Invariant(FormattableString):静态方法,使用 InvariantCulture 格式化。
  • 代码示例

    var dateOfBirth = new DateTime(1976, 6, 19);
    
    // 转换为 FormattableString(编译器调用 FormattableStringFactory.Create)
    FormattableString formattable = $"Jon was born on {dateOfBirth:d}";
    
    // 延迟指定 culture 进行格式化
    var culture = CultureInfo.GetCultureInfo("en-US");
    string result = formattable.ToString(culture);  // "Jon was born on 6/19/1976"
    
    // 使用 InvariantCulture 格式化
    string invariantResult = FormattableString.Invariant(formattable);
    
    // 编译器转换示意
    int x = 10, y = 20;
    FormattableString fs = $"x={x}, y={y}";
    // 等价于:
    // FormattableString fs = FormattableStringFactory.Create("x={0}, y={1}", x, y);
    
  • 面试准备建议

    • 重要性:⭐⭐(理解延迟格式化和本地化机制)
    • 面试回答要点
      • 普通内插字符串立即格式化为 string,使用当前线程 culture。
      • FormattableString 用于延迟格式化:当内插字符串赋值给 FormattableStringIFormattable 时,编译器保留复合格式串和实参,允许稍后指定 CultureInfo 再格式化。
      • 典型场景:需要以用户所选语言/区域显示内容时,可先存储 FormattableString,再根据用户设置进行本地化。
      • string.Format 的关系:FormattableString 将原本一次性完成的格式化拆分为“准备数据”和“选择文化并格式化”两步。
      • 可提及 FormattableString.Invariant 用于固定文化格式化(如日志、协议),避免依赖当前线程文化。
      • Unity 中使用场景:多语言 UI 文本展示,可在本地化系统中封装 FormattableString,根据玩家语言偏好格式化显示。

9.3.2 在特定 culture 下格式化 FormattableString

  • 核心概念FormattableString 的核心价值在于延迟格式化,允许显式指定 IFormatProvider(如 CultureInfo)再生成最终字符串,避免依赖当前线程的默认 culture。Invariant 静态方法是其最常用的快捷方式。

  • 关键点

    • FormattableString.Invariant 方法
      • 等价于调用 ToString(CultureInfo.InvariantCulture),但作为静态方法更易调用。
      • 接收 FormattableString 参数,因此可以直接传入内插字符串字面量,编译器会自动转换为 FormattableString
    • 将内插字符串转换为 FormattableString 的几种方式
      • 强制类型转换:((FormattableString)$"..."),然后调用 ToString(culture)
      • 隐式转换:直接赋值给 FormattableString 变量,或作为 FormattableString 参数传递。
      • 使用 using static System.FormattableString 后,可直接写 Invariant($"..."),最简洁。
    • 在非 invariant culture 下格式化:调用 ToString(IFormatProvider) 并传入特定 CultureInfo
    • IFormattable 接口实现FormattableString 实现了 IFormattable,其 ToString(string, IFormatProvider) 方法忽略第一个参数,直接使用 IFormatProvider 完成格式化,便于融入已有 API。
    • 注意事项
      • 即使自定义格式串只包含数字,DateTime 的格式化仍受 culture 的日历系统影响(例如不同历法导致年份不同)。
      • 机器间通信始终使用 invariant culture,避免不可预知的结果。
  • 代码示例

    DateTime date = DateTime.UtcNow;
    
    // 传统 string.Format + InvariantCulture
    string p1 = string.Format(CultureInfo.InvariantCulture, "x={0:yyyy-MM-dd}", date);
    
    // 强制转换为 FormattableString 再调用 ToString
    string p2 = ((FormattableString)$"x={date:yyyy-MM-dd}")
                 .ToString(CultureInfo.InvariantCulture);
    
    // 使用 FormattableString.Invariant 静态方法
    string p3 = FormattableString.Invariant($"x={date:yyyy-MM-dd}");
    
    // 使用 using static 简化调用
    using static System.FormattableString;
    string p4 = Invariant($"x={date:yyyy-MM-dd}");
    
    // 非 invariant culture 示例
    FormattableString fs = $"The time is: {DateTime.Now:g}";
    string us = fs.ToString(CultureInfo.GetCultureInfo("en-US"));
    
  • 面试准备建议

    • 重要性:⭐⭐(理解本地化与延迟格式化)
    • 面试回答要点
      • FormattableString 让内插字符串的格式化与 IFormatProvider 解耦,方便实现本地化。
      • FormattableString.Invariant 是固定文化格式化的快捷方式,适合日志、协议、数据交换等场景。
      • 编译器会根据上下文自动将内插字符串转换为 FormattableString:当目标类型为 FormattableStringIFormattable 时,会调用 FormattableStringFactory.Create,而不是立即调用 string.Format
      • string.Format 相比,FormattableString 分离了“数据采集”和“呈现”,更符合单一职责,也便于单元测试。
      • 可提及:在 Unity 的多语言 UI 中,可以将 FormattableString 保存为本地化键对应的模板,运行时根据玩家语言调用 ToString(culture)

📦9.3.3 FormattableString 的其他用途(以 SQL 参数化为例)

  • 核心概念:利用 FormattableString 分离格式串与参数值,可在不牺牲安全性的前提下,用接近自然书写的方式构建参数化 SQL,避免 SQL 注入。

  • 关键点

    • SQL 注入的本质:将代码(SQL)与数据(用户输入)混杂,通常由字符串拼接或 string.Format 引起。
    • FormattableString 的优势
      • 保留原始格式串中的占位符和实参,允许后续单独处理每个参数。
      • 结合自定义格式串(如 {tag:NVarChar})传递额外元数据(如数据库类型)。
    • 实现思路
      • 编写扩展方法(如 NewSqlCommand),接受 FormattableString 而非 string
      • 提取实参,为每个实参创建 SqlParameter(名称自动生成如 @p0, @p1)。
      • 利用实现了 IFormattable 的辅助类(如 FormatCapturingParameter),在 ToString(string format, ...) 中将格式串解析为 SqlDbType,同时返回参数名。
      • 最终用 string.Format 重新组合出安全的参数化 SQL(参数名占位),参数值通过 SqlCommand.Parameters 添加。
    • 风险与权衡
      • 代码看似有注入风险,实际安全,但可能误导不熟悉该模式的开发者或静态分析工具。
      • 需要在团队中明确约定和文档支持,判断是否“小题大做”。
    • 版本兼容:C# 6 编译器不限制目标框架,只要类型 FormattableString 可用即可;旧框架可通过 NuGet 或自行定义类来支持。
  • 代码示例(核心结构,基于原文提炼):

    // 安全的 SQL 命令构建(调用端)
    using (var command = conn.NewSqlCommand(
        $"SELECT Description FROM Entries WHERE Tag={tag:NVarChar} AND UserId={userId:Int}"))
    {
        // 使用 command 执行查询
    }
    
    // 扩展方法实现的关键部分(伪代码)
    public static SqlCommand NewSqlCommand(this SqlConnection conn, FormattableString fs)
    {
        var args = fs.GetArguments();
        // 为每个参数生成 SqlParameter,并通过 IFormattable 捕获类型信息
        var formatArgs = args.Select((val, i) => new FormatCapturingParameter(
            new SqlParameter($"@p{i}", val))).ToArray();
        string sql = string.Format(fs.Format, formatArgs);
        // ... 将参数添加到 command
    }
    
  • 面试准备建议

    • 重要性:⭐(拓展视野,了解设计模式)
    • 面试回答要点
      • FormattableString 不只是用于本地化,还可以安全地分离格式模板与值。
      • 可举例 SQL 参数化:通过 FormattableString 捕获参数值,自动生成带参数的 SQL,杜绝注入。
      • 核心思想是“延迟格式化”,将模板和数据的绑定推迟到合适的执行点,以实现安全或文化适配。
      • Unity 中一般不会直接拼接 SQL,但这种模式可迁移到其他需要安全拼接命令字符串的场景(如自定义网络协议、资源路径构建)。
      • 若被问到“如何防止字符串注入”,除了常规的转义/参数化外,可以提这种编译器辅助的安全字符串构建思路。

9.3.4 在旧版 .NET 中使用 FormattableString

  • 核心概念:C# 编译器不关心 FormattableStringFormattableStringFactory 来自哪个程序集,只要在正确的命名空间下提供匹配的类和方法即可。因此即使目标框架未内置,也可自行实现以启用内插字符串到 FormattableString 的转换。

  • 关键点

    • 编译器的依赖规则
      • 要求 System.Runtime.CompilerServices.FormattableStringFactory 类包含静态方法 Create(string format, params object[] arguments)
      • 要求 System.FormattableString 类(或抽象类)与内插字符串转换所需的成员匹配。
    • 自行实现的注意事项
      • 应该视为最后手段,仅在无法升级到 .NET 4.6+ 时使用。
      • 应将类型设为 internal,避免其他程序集意外依赖,减少命名冲突风险。
      • 一旦目标框架升级,立即删除自定义类型,防止编译器混淆或诊断困难。
    • 实现要点(基于代码清单 9-10):
      • FormattableStringFactory.Create 返回 FormattableString 实例。
      • FormattableString 保存 Format(复合格式串)和 arguments 数组。
      • 实现 ToString(IFormatProvider),内部调用 string.Format(provider, Format, arguments)
      • IFormattable.ToString 忽略第一个参数,直接委托给 ToString(IFormatProvider)
      • 静态方法 Invariant 调用 ToString(CultureInfo.InvariantCulture),其中使用了空值条件运算符 ?.(C# 6 特性)。
  • 代码示例(简化,展示核心结构):

    namespace System.Runtime.CompilerServices
    {
        internal static class FormattableStringFactory
        {
            internal static FormattableString Create(string format, params object[] args)
                => new FormattableString(format, args);
        }
    }
    
    namespace System
    {
        internal class FormattableString : IFormattable
        {
            public string Format { get; }
            private readonly object[] arguments;
    
            internal FormattableString(string format, object[] arguments)
            {
                Format = format;
                this.arguments = arguments;
            }
    
            public object GetArgument(int index) => arguments[index];
            public object[] GetArguments() => arguments;
            public int ArgumentCount => arguments.Length;
    
            public static string Invariant(FormattableString formattable) =>
                formattable?.ToString(CultureInfo.InvariantCulture);
    
            public string ToString(IFormatProvider provider) =>
                string.Format(provider, Format, arguments);
    
            string IFormattable.ToString(string ignored, IFormatProvider provider) =>
                ToString(provider);
        }
    }
    
  • 面试准备建议

    • 重要性:⭐(了解技巧,面试极少深入)
    • 面试回答要点
      • 当需要在旧框架使用新编译器特性时,可以通过“类型伪造”(forward-compatible polyfill)满足编译器需求。
      • 编译器只检查类型的命名空间、名称和成员签名,不检查程序集来源。因此可以自己定义 FormattableStringFormattableStringFactory
      • 实施时要设为 internal 避免版本冲突,并在目标框架升级后移除。
      • 可对比类似案例:调用方信息特性(CallerMemberName 等)也可通过同样方式在旧 .NET 中使用。
      • 面试时可强调这是一种“兼容桥接”技巧,体现了对编译原理的理解。

9.4 使用指南与限制

9.4.1 内插字符串的使用指南与限制

  • 核心概念:内插字符串适用于硬编码格式串的场景,能大幅提升可读性,但不适用于需要动态格式串或资源化的最终用户界面。根据字符串消费者类型选择合适的方案。

  • 关键点

    • 硬编码格式串是前提:格式串必须在源码中确定,编译器才能拆分。无法在运行时动态改变模板。
    • 三类消费者与适用性
      1. 机器可读的字符串(日志、URL参数、XML/JSON等):
        • 必须使用 InvariantCulture,适合用 FormattableString.Invariant 或强制转换。
        • 优先使用框架内置的序列化/格式化API,而不是手动拼接。
      2. 为其他开发人员提供的消息(工具帮助、诊断日志、异常消息):
        • 通常用英文,不做本地化。内插字符串非常适合。
        • 日期等应使用无歧义的格式(如 yyyy-MM-dd),并考虑使用 Invariant 固定日历,避免不同文化下的歧义。
        • 示例:throw new ArgumentException(Invariant($"Start date {start:yyyy-MM-dd} should not be earlier than year 2000."));
      3. 为最终用户提供的消息
        • 如果所有用户共享同一文化,可直接使用内插字符串。
        • 如果需要支持多语言/多文化,格式串应放入资源文件,此时内插字符串基本不适用(无法在资源中嵌入表达式)。
        • 仅对一小段内嵌信息可能有用,但收益甚微。
    • 完全不适用的场景(后文将展开):
      • 格式串存储在资源文件或外部配置中。
      • 需要运行时动态构建格式串。
    • 选择指南:可随时在内插和 string.Format 之间切换,除非 API 暴露了 FormattableString,否则它只是实现细节。
  • 代码示例

    // 机器通信:使用 Invariant 固定格式化
    string urlParam = Invariant($"date={date:yyyy-MM-dd}");
    
    // 异常消息给开发人员
    throw new ArgumentException(Invariant(
        $"Start date {start:yyyy-MM-dd} should not be earlier than year 2000."));
    
    // 同一文化的最终用户
    Console.WriteLine($"Your total is {total:C}"); // 依赖当前线程的Culture
    
  • 面试准备建议

    • 重要性:⭐⭐(理解使用边界,避免滥用)
    • 面试回答要点
      • 内插字符串适合硬编码、固定格式的场景,尤其对开发人员消息(日志、异常)非常友好。
      • 需要本地化的用户界面字符串应使用资源文件,而非内插字符串。
      • 机器间通信必须使用 InvariantCulture,且优先采用框架的序列化工具,防止手动格式化出错。
      • 可提及在 Unity 中,Debug.Log 等调试输出用内插字符串非常方便,但针对玩家的多语言文本应使用本地化系统,避免硬编码字符串。
      • 理解 FormattableString 允许延迟指定 culture,是内插字符串在本地化场景中的有限扩展,但仍不替代资源文件。

📦9.4.2 内插字符串字面量的硬性限制

  • 核心概念:内插字符串有一些编译器限制,包括无法动态改变格式项、表达式立即求值、以及条件运算符中的冒号冲突,了解这些可避免踩坑。

  • 关键点

    • 无法动态格式化
      • 对齐值、格式串等必须是编译时常量或内插表达式的结果,但无法将整个格式项(如 {0,9})动态生成。
      • 变通方法:用内插字符串生成一个包含占位符的字符串,再作为 string.Format 的格式串,但可读性差。
      • 示例:Console.WriteLine($"Price: {{0,{alignment}:C}}", price); 不推荐。
    • 表达式立即求值
      • 内插字符串在构造时立刻计算所有表达式的值,生成最终字符串或 FormattableString,不会延迟或重复计算。
      • 即使赋值给 FormattableString,表达式结果也已固定,改变后续变量不会影响已生成的实例。
      • 避免误认为 FormattableString 可以捕获变量引用。
    • 纯冒号问题
      • 条件运算符 ?: 中的 : 会被解析为格式串分隔符,导致编译错误。
      • 解决办法:用括号包裹整个条件表达式,如 {(age >= 18 ? "Yes" : "No")}
      • 最佳实践:过长的表达式应抽取为变量或方法,保持内插字符串简洁。
  • 代码示例

    // 动态对齐的无奈变通(不推荐)
    int alignment = GetAlignment();
    string result = string.Format($"Price: {{0,{alignment}:C}}", price);
    
    // 表达式立即求值演示
    string value = "Before";
    FormattableString fs = $"Current value: {value}";
    value = "After";
    Console.WriteLine(fs); // 仍输出 "Before"
    
    // 条件运算符需加括号
    Console.WriteLine($"Adult? {(age >= 18 ? "Yes" : "No")}");
    
  • 面试准备建议

    • 重要性:⭐(了解限制,避免低级错误)
    • 面试回答要点
      • 内插字符串不是动态模板引擎,格式串固定,表达式立即求值。
      • FormattableString 同样会立刻求值,不能保留变量引用;若需要延迟执行,可考虑封装 lambda,但极其不推荐。
      • 条件运算符使用时必须加括号,否则冒号被误解析。
      • 总体原则:内插字符串适合简单、固定的格式化;复杂动态格式应使用 string.Format 或专门的模板方案。
      • 若被问到如何做动态对齐,可回答通常重新计算或改用 string.Format 传递参数的方式,但不值得牺牲可读性。

📦9.4.3 滥用内插字符串的场景与避免建议

  • 核心概念:并非所有字符串格式化都适合用内插字符串,延迟格式化、复杂表达式和过长内插都会损害可读性与性能。

  • 关键点

    • 推迟不必要的格式化
      • 若格式化结果仅在特定条件下使用(如日志级别过滤、条件校验失败时才抛出异常),使用内插字符串会立即执行格式化,造成不必要的开销。
      • 如果方法接受 FormattableString 参数,可将格式化推迟,但仍会产生对象分配,不如直接传原始值和格式串。
      • 建议:日志、条件异常等场景保留 string.Format 重载或参数化方法,让方法内部决定是否格式化。
    • 避免可读性降低
      • 表达式过长或包含方法调用、括号时,内插字符串会混杂代码与文本,难以阅读。
      • 多参数垂直排列的 string.Format 有时更清晰。
      • 极端情况:将多条语句压缩进内插字符串(通过 lambda)虽可行,但严重牺牲可读性,应杜绝。
    • 决策原则:简单、简短的表达式适合内插;格式化可能被跳过或表达式复杂时,用传统 string.Format 或拆分变量。
  • 代码示例

    // 滥用:日志可能不输出,但仍格式化了字符串
    Logger.Debug($"Received request with {request.Length} bytes");
    
    // 更优:让日志框架决定是否格式化
    Logger.Debug("Received request with {0} bytes", request.Length);
    
    // 可读性对比:多参数用 string.Format 更整洁
    string FormatMemberDebugName(MemberInfo m) =>
        string.Format("{0}.{1}({2})",
            m.DeclaringType.Name,
            m.Name,
            string.Join(", ", GetParameters(m).Select(p => p.ParameterType)));
    
    // 切勿模仿:将多条语句塞进内插字符串
    Console.WriteLine($@"Hello, {((Func<string>)(() =>
    {
        Console.Write("What's your name? ");
        return Console.ReadLine();
    }))()}!");
    
  • 面试准备建议

    • 重要性:⭐(编码规范,适当提及可体现意识)
    • 面试回答要点
      • 内插字符串在生成时立即求值,若结果可能被丢弃(如日志级别过滤),应使用参数化方法避免无用格式化。
      • 表达式应短小精悍,过长或嵌套复杂时,用 string.Format 或临时变量提高可读性。
      • 强调“代码是写给人看的”,内插字符串是提升可读性的工具,不能本末倒置。
      • Unity 中 Debug.Log 经常调用,注意若日志在发布版本中被剥离,内插字符串的开销仍然发生;可使用条件编译或参数化版本来优化。

9.5 nameof 访问标识符

❗️9.5.1 nameof 运算符(C# 6)

  • 核心概念nameof 运算符在编译时返回标识符(类、方法、字段、参数、属性等)的名称字符串,用于替代硬编码的字符串字面量,提高代码稳健性和可维护性。

  • 关键点

    • 语法nameof(标识符),类似于 typeof,但操作数不必是类型,可以是任何成员或变量。
    • 编译时常量nameof 的结果是编译时确定的字符串常量,不依赖运行时反射。
    • 优势
      • 编译时检查:拼写错误会导致编译错误,而非运行时才发现问题。
      • 重构友好:重命名标识符时,nameof 内的名称会自动同步修改(Visual Studio 等 IDE 的重命名功能会将其视为引用)。
      • 消除魔法字符串:避免代码中出现裸字符串字面量表示成员名称。
    • 适用场景
      • 异常参数名(ArgumentNullExceptionparamName)。
      • INotifyPropertyChanged 中的属性名(与 CallerMemberName 互补,当需要指定其他属性时)。
      • 日志、诊断输出中引用成员名称。
      • 数据绑定、反射等任何需要传递成员名称的地方。
    • 注意事项
      • 如果标识符拼写恰好与另一个存在的成员相同,编译无法检查出逻辑错误(如 filename vs fileName),应避免这种容易混淆的命名。
  • 代码示例

    class SimpleNameof
    {
        private string field;
    
        static void Main(string[] args)
        {
            Console.WriteLine(nameof(SimpleNameof)); // "SimpleNameof"
            Console.WriteLine(nameof(Main));        // "Main"
            Console.WriteLine(nameof(args));        // "args"
            Console.WriteLine(nameof(field));       // "field"
        }
    }
    
    // 常见用法:方法中使用 nameof 输出参数名
    static void RenameDemo(string oldName)
    {
        Console.WriteLine($"{nameof(oldName)} = {oldName}");
    }
    
  • 面试准备建议

    • 重要性:⭐⭐⭐(高频考点,必会)
    • 面试回答要点
      • nameof 运算符是 C# 6 引入的编译时特性,返回标识符的名称字符串。
      • 与传统硬编码字符串相比,它提供编译时错误检查,并随重构自动更新,避免魔法字符串带来的维护问题。
      • 常见使用场景:抛出 ArgumentNullException 时指定参数名(throw new ArgumentNullException(nameof(param)));在 INotifyPropertyChanged 中传递属性名(配合 CallerMemberName 或单独使用);日志记录成员名称等。
      • 面试时可强调“代码的健壮性”和“减少运行时异常”,并可对比 nameoftypeofCallerMemberName 的区别与联系。

❗️9.5.2 nameof 运算符的常见用法

  • 核心概念nameof 运算符将标识符转换为编译时常量字符串,主要用于替代硬编码的成员名称,提高代码的健壮性和可维护性。

  • 关键点

    • 参数校验:将 nameof(参数名) 作为参数名传给 ArgumentNullException 等异常,是最常见的用法。
      • 优点:重命名参数时自动同步,编译期检查拼写错误。
    • 属性变化通知(INotifyPropertyChanged)
      • 当一个属性改变会影响到另一个计算属性时,可用 nameof(计算属性) 安全地触发通知。
      • 避免使用魔法字符串 "Area" 或构建表达式树,性能更好且更简洁。
    • Attribute 中指代成员
      • 某些 attribute 需要指定另一个成员的名称(如 TestCaseSourceForeignKey、自定义 DerivedProperty)。
      • typeof 只能指代类型,而 nameof 可指代字段、属性、方法等任何成员。
      • 示例:[TestCaseSource(nameof(AllZones))][ForeignKey(nameof(Employer))][DerivedProperty(nameof(Area))]
  • 代码示例

    // 1. 参数校验
    public ZonedDateTime InZone(DateTimeZone zone, ZoneLocalMappingResolver resolver)
    {
        Preconditions.CheckNotNull(zone, nameof(zone));
        Preconditions.CheckNotNull(resolver, nameof(resolver));
        return zone.ResolveLocal(this, resolver);
    }
    
    // 2. 属性变更通知(计算属性联动)
    public double Width
    {
        set
        {
            if (width == value) return;
            width = value;
            RaisePropertyChanged();             // 用 CallerMemberName 传 "Width"
            RaisePropertyChanged(nameof(Area)); // 显式传 "Area"
        }
    }
    public double Area => Width * Height;
    
    // 3. Attribute 指代成员
    static readonly IEnumerable<DateTimeZone> AllZones = ...;
    [Test]
    [TestCaseSource(nameof(AllZones))]
    public void AllZonesStartAndEnd(DateTimeZone zone) { ... }
    
    public class Employee
    {
        [ForeignKey(nameof(Employer))]
        public Guid EmployerId { get; set; }
        public Company Employer { get; set; }
    }
    
  • 面试准备建议

    • 重要性:⭐⭐⭐(高频考点,必须掌握)
    • 面试回答要点
      • nameof 用于在编译时获取成员名称字符串,消除硬编码字符串。
      • 核心优势:编译时错误检查、重构安全(重命名自动同步)。
      • 最常见场景:参数校验(异常构造器中的 paramName);INotifyPropertyChanged 中传递属性名;attribute 中指代其他成员。
      • 能对比 nameoftypeofCallerMemberName 的区别:nameof 返回任何标识符的名称;typeof 返回类型的 Type 对象;CallerMemberName 自动获取调用方名称,用于触发事件时省去显式参数。
      • 在 Unity 面试中可举例:编辑器脚本中自定义 PropertyDrawer 或编写可重用的校验工具时,用 nameof 避免字符串拼写错误。

📦9.5.3 nameof 运算符的技巧与陷阱

  • 核心概念nameof 返回标识符的编译时常量字符串,但它在泛型、别名、预定义类型、命名空间等边缘情况下有特定行为,需要注意以免误用。

  • 关键点

    • 引用其他类型的成员
      • 可以使用类型名(推荐)或该类型的变量名访问实例/静态成员,变量值无关紧要(甚至可为 null)。
      • 成员必须是可访问的(public 等),否则编译错误。
      • 匿名类型只能通过变量来获取成员名称。
    • 泛型
      • 必须指定类型实参,但返回的名称不包含类型实参或类型形参个数。
      • 例:nameof(Action<string>)nameof(Action<string, string>) 都返回 "Action"
      • 可直接使用类型形参:nameof(T) 永远返回 "T",不是调用时传递的实际类型名称。
    • 别名
      • 如果使用了 using 别名(如 using GuidAlias = System.Guid;),nameof(GuidAlias) 返回 "GuidAlias",而不是 "Guid"
    • 不支持直接使用预定义别名、可空简写和数组
      • 非法:nameof(float)nameof(Guid?)nameof(String[])
      • 变通:使用 CLR 名称,如 nameof(Single)nameof(Nullable<Guid>)(注意泛型可空只返回 "Nullable")。
    • 只返回“简单名称”
      • 不包含命名空间前缀:nameof(System.Guid) 返回 "Guid"
      • 对于命名空间本身:nameof(System.Collections.Generic) 返回 "Generic",实用价值不大。
      • 重载方法、同名的属性和类型无法区分,均返回相同名称。
    • infoof 的对比nameof 只是一个简单的名字获取器,无法获取 MethodInfo 等更丰富的反射信息,但胜在简单易用。
  • 代码示例

    // 引用其他类型的成员
    OtherClass instance = null;
    Console.WriteLine(nameof(instance.InstanceMember)); // 允许,即使 instance 为 null
    Console.WriteLine(nameof(OtherClass.StaticMember));
    Console.WriteLine(nameof(OtherClass.InstanceMember));
    
    // 泛型
    Console.WriteLine(nameof(List<int>));       // "List"
    Console.WriteLine(nameof(Action<string>));  // "Action"
    Console.WriteLine(nameof(T));               // "T"
    
    // 别名
    using GuidAlias = System.Guid;
    Console.WriteLine(nameof(GuidAlias));       // "GuidAlias"
    
    // 预定义别名等限制
    // nameof(float) 非法
    Console.WriteLine(nameof(Single));          // "Single"
    // nameof(Guid?) 非法
    Console.WriteLine(nameof(Nullable<Guid>));  // "Nullable"
    
    // 命名空间仅返回最后一段
    Console.WriteLine(nameof(System.Collections.Generic)); // "Generic"
    
  • 面试准备建议

    • 重要性:⭐(了解即可,极少深入考查)
    • 面试回答要点
      • nameof 返回标识符的简单名称(无命名空间,无泛型参数),是编译时常量。
      • 常见陷阱:泛型名称不带类型实参;别名返回别名本身;不能用于 intfloat 等预定义关键字和 T?、数组简写。
      • 如果被问到 nameof(List<string>) 的结果,答 "List" 即可,并说明泛型参数会被省略。
      • 在 Unity 开发中,大部分场景用 nameof 做参数校验或属性通知,一般不会触及这些边界情况。但了解其行为可避免在自定义编辑器工具或代码生成时出现预期外的结果。
posted @ 2026-07-28 19:41  绘星tsuki  阅读(1)  评论(0)    收藏  举报