C#筆記 – 文本處理

字符
  • 表示成16位Unicode代碼值

  • 每個字符都是System.Char結構的實例

    • 提供了兩個常量字段

      • MinValue:’\0′

      • MaxValue:’\uffff’

    • 靜態方法

      • GetUnicodeCategory

        • 返回System.Globalization.UnicodeCategory枚舉類型的一個值,表示該字符的類型(字母/數字/標點/……)

        • IsDigit/IsLetter/IsUpper等靜態方法都在內部調用了該方法

      • ToLower(Invariant)/ToUpper(Invariant)

        • 將字符轉換為小寫/大寫(是否需要忽略語言文化)

    • 實例方法

      • GetNumericValue(返回字符的數值形式)

        • 非數字將會返回-1

  • 數值類型與Char實例轉換

    • 強轉

      • char c;            
        c = (char)65;
        Console.WriteLine($"Char: {c}");
      • IL_0000:  nop
        IL_0001: ldc.i4.s   65
        IL_0003: stloc.0
      • 效率最高

      • 由編譯器生成IL指令來轉換,不必調用方法

      • 可以指定轉換時使用checked還是unchecked

    • Convert類

      • char c;            
        c = Convert.ToChar(65);
        Console.WriteLine($"Converted: {c}");
      • IL_0019:  nop
        IL_001a: ldc.i4.s   65
        IL_001c: call       char [System.Runtime.Extensions]System.Convert::ToChar(int32)
        IL_0021: stloc.0
      • 效率一般

      • Convert類提供了幾個靜態方法,以checked方式執行

    • IConvertible接口

      • char c;            
        c = ((IConvertible)65).ToChar(null);
        Console.WriteLine($"Interface: {c}");
      • IL_0037:  nop
        IL_0038: ldc.i4.s   65
        IL_003a: box       [System.Runtime]System.Int32
        IL_003f: ldnull
        IL_0040: callvirt   instance char [System.Runtime]System.IConvertible::ToChar(class [System.Runtime]System.IFormatProvider)
        IL_0045: stloc.0
      • 效率最差

      • 值類型調用接口方法要求裝箱

字符串
  • 字符串是一個「不可變的順序字符集」,直接派生自Object,屬於引用類型

  • 包括C#以內的很多語言將string視為基元類型

    • 編譯器允許源代碼直接使用字面值字符串,將這些字面值放到元數據中,在運行時加載和引用

    • string s = "Hello World";
      Console.WriteLine(s);
    • User Strings
      -------------------------------------------------------
      70000001 : (11) L"Hello World"
      • 字面值字符串會被加載到元數據中

    • IL_0000:  nop
      IL_0001: ldstr     "Hello World"
      IL_0006: stloc.0
      • ldstr(Load String)指令會被調用,從元數據中獲得對應的字面值並構造對象

  • 字符串連接

    • 多個字符串可以通過 ‘+’ 操作符被連接起來

    • 如果字符串都是字面值,C#編譯器就會在編譯時將它們連接起來並放到元數據中

      • Console.WriteLine("AA" + " " + "DD"); //Connected in Compile Stage
      • 7000001f : ( 5) L"AA DD"
    • 如果非字面值(變量),連接則在運行時進行

      • string s = "Hello World";
        Console.WriteLine(s + "BB"); //Not Connect in Compile Stage
      • 70000001 : (11) L"Hello World"
        70000019 : ( 2) L"BB"
      • 這類連接不要使用”+”操作符來連接,避免在堆上創建多個字符串對象,產生GC影響性能

      • 應該使用StringBuilder進行連接

  • 逐字字符串

    • 在字符串前加上“@”,使一個字符串被聲明為「逐字字符串」,表示引號中的所有字符會被視為字符串的一部分

    • string path = @"C:\XXXX\XXXX\XXX.txt";
字符串的不可變
  • 字符串一經創建就不會被改變

    • 其所有對象方法,如ToUpper、Substring等會創建完成指定操作的臨時字符串副本,只要不保存該副本引用,該副本就會在使用完畢後被GC回收

    • 如果需要執行大量字符串操作,就會導致大量臨時字符串被創建,造成更頻繁的GC,應用StringBuilder取代

    • 避免了線程同步問題

  • CLR可通過一個string對象共享多個完全一致的string內容,減少系統中的字符串數量 => 字符串留用

  • 出於性能考量,CLR知道string類中的字段布局,並會直接訪問這些字段

    • 導致string必須是密封類,以防止CLR對於string的預設被破壞

字符串的比較
  • 判斷相等性/排序時,最好調用string類定義的方法

    • Equals

    • Compare

    • StartsWith

    • EndsWith

  • 這些方法中,很多會要求獲取一個StringComparison枚舉類型的參數

    • namespace System
      {
         public enum StringComparison
        {    
             CurrentCulture = 0,
             CurrentCultureIgnoreCase = 1,
             InvariantCulture = 2,
             InvariantCultureIgnoreCase = 3,
             Ordinal = 4,
             OrdinalIgnoreCase = 5
        }    
      }
    • 如果要以語言文化正確的方式來比較字符串,應傳遞CurrentCulture(IgnoreCase)參數

    • 盡量避免使用InvariantCulture(IgnoreCase),比較所花時間遠超使用「序號比較」的Ordinal(IgnoreCase)

      • 「序號比較」:不考慮語言文化信息,只比較字符串中每個Char的Unicode碼位

        • 序號比較前,要先用ToUpperInvariant來對字符串進行正規化

  • 少部分會要求獲取一個CompareOptions枚舉類型的參數

    • namespace System.Globalization
      {
        [Flags]
         public enum CompareOptions
        {
             None = 0,
             IgnoreCase = 1,
             IgnoreNonSpace = 2,
             IgnoreSymbols = 4,
             IgnoreKanaType = 8,
             IgnoreWidth = 16,
             OrdinalIgnoreCase = 268435456,
             StringSort = 536870912,
             Ordinal = 1073741824
        }
      }
    • 接受該實參的方法要求顯式傳遞語言文化,Ordinal(IgnoreCase)會忽略語言文化的比較

  • 避免使用string.CompareTo、CompareOrdinal、==操作符、!=操作符來比較字符串

    • 這些比較方法無法從使用上看出比較方式(序號比較?字符展開?對語言文化是否敏感?)

      • 「字符展開」:將一個字符展開成忽視語言文化的多個字符

  • CultureInfo

    • 這個類表示一個「語言/國家」

    • 在CLR中,每個線程關聯了兩個屬性,每個屬性又引用一個CultureInfo對象

      • CurrentUICulture

        • UI元素的語言

      • CurrentCulture

        • 數字/日期格式化

        • 字符串大小

        • 字符串比較

    • CultureInfo內部的一個字段引用了一個System.Globalization.CompareInfo對象

      • 該對象封裝了語言文化的字符排序表信息(根據Unicode標準定義)

      • 每種語言文化只有1個CompareInfo對象

    • 調用string.Compare時:

      • 如果指定了語言文化(CultureInfo),就會使用指定的語言文化;沒有則使用線程的CurrentCulture屬性

      • Compare方法內部會獲取該CultureInfo的CompareInfo對象引用,並調用其Compare方法

        • 該Compare方法獲取來自CompareOptions枚舉類型的值作為參數,該枚舉定義的符號代表一組位標志(bit flag)

        • 對這些bit flag進行OR運算可進行更全面的控制

字符串留用
  • 由於字符串是不可變的,所以一般而言,一個字符串在內存裡只需要一個實例

    • 引用字符串的所有變量只需指向單獨一個字符串對象

    • string s1 = "Hello";
      string s2 = "Hello";
      Console.WriteLine(Object.ReferenceEquals(s1, s2)); //True => 已留用
  • CLR初始化時會創建一個內部哈希表

    • 鍵為字符串;值為對托管堆string對象的引用

    • string類提供了兩個方法訪問該哈希表

      • Intern(string str)

        • 該方法獲取一個string參數,獲取其哈希碼其檢查內部哈希表中是否有匹配項,有則返回該引用;沒有則創建副本,添加到內部哈希表中,返回該引用

        • GC不能釋放內部哈希表引用的字符串,除非AppDomain卸載/進程終止

          • 也就是Interned了的字符串,即使在代碼上看起來已經沒有被引用,其內存也無法被釋放

      • IsInterned(string str)

        • 該方法同樣在內部哈希表中檢查字符串是否存在;但在找不到的情況下,不會創建副本和添加到內部哈希表中,並返回null

  • 字符串留用的使用需要謹慎

    • 除非顯式調用string的Intern方法,否則不要以「字符串已留用」的前提來寫代碼

    • 可檢查程序集是否使用了System.Runtime.CompilerServices.CompilationRelaxationsAttribute進行標記且指定了NoStringInterning標志值

  • 字符串池

    • 由於在編譯源代碼時,編譯器需要將字面值字符串嵌入到元數據中,如果同一個字符串在源碼中多次出現,就會導致元數據無意義的膨脹。

      • 因此,包括C#以內的很多編譯器會確保同一字符串的字面值字符串只在元數據中寫入一次

      • 引用該字符串的所有代碼被修改成引用元數據中的同一字符串

      • 編譯器將單個字符串的多個實例合併成一個實例

StringBuilder
  • 用於對字符串和字符進行動態處理,並返回處理好的String對象

    • 包含一個引用了Char結構數組的字段

    • 使用ToString方法將Char數組轉換成string

    • StringBuilder sb = new StringBuilder();
  • StringBuilder的三個關鍵概念

    • 最大容量

      • 能放到字符串的最大字符數,默認為Int.MaxValue

      • 一旦構造好就不能改變

    • 容量

      • StringBuilder維護的字符數組長度,默認為16

      • 向字符數組追加字符時,會檢查數組長度會否超過容量,會則自動倍增容量,用新容量來分配新數組,並將原始數組的字符複製到新數組中

        • 原始數組之後會被GC

        • 動態擴容會損害性能,盡量一開始設置一個合理的容量

    • 字符數組

      • 「字符串」的具體字符內容容器

  • StringBuilder代表「可變字符串」,因此其大多數成員都能更改字符數組的內容,且不會造成在托管堆上分配新對象,只有以下兩種情況會導致新對象需要被分配

    • 動態構造字符串(長度 > 設置的容量)

    • 調用ToString()方法

  • 大多數StringBuilder的方法返回的是StringBuilder對象的引用,而不是String對象的引用;與此同時,StringBuilder又不與String提供的方法完全對應(如ToLower、ToUpper等),有時候需要StringBuilder和String兩者之間轉換來達成目標需求

ToString
  • System.Object定義了一個公開無參虛方法ToString,返回代表對象當前值的字符串/對象所屬類型的全名

    • 問題:

      • 無法控制字符串的格式

      • 無法指定特定語言文化格式化字符串

  • 類應實現IFormattable以及其接口方法

    • namespace System
      {    
        [NullableContextAttribute(2)]
         public interface IFormattable
        {        
            [return: NullableAttribute(1)]
             string ToString(string? format, IFormatProvider? formatProvider);
        }
      }
    • 該接口的ToString方法獲取兩個參數

      • string? format

      • IFormatProvider? formatProvider

        • 實現了System.IFormatProvider接口的類型實例,提供具體的語言文化信息

          • 比如CultureInfo類,因此,可以構造一個CultureInfo類型並以其作為formatProvider的實參

            • int p = 10;
              string s = p.ToString("G", new CultureInfo("en-US"));
  • 對象格式化成字符串

    • String.Format/StringBuilder.AppendFormat

      • string formatStr = String.Format("Hello {0:E} World", 2);
      • Format方法會依次調用格式字符串(第1個參數)後的每個對象的ToString方法

      • 格式字符串中的大括號可以指定格式信息

    • 定制格式化器

      • String.Format/StringBuilder.AppendFormat方法的第一個參數除了可以接受格式字符串以外,還可以接受一個實現了IFormatProvider接口的類實例

      • 如果這個實例還實現了ICustomFormatter接口,那麼在String.Format/StringBuilder.AppendFormat獲取對象字符串表示時,就會調用這個接口的Format方法

      • public class CustomFormatter : IFormatProvider, ICustomFormatter
        {
           public string Format(string format, object arg, IFormatProvider formatProvider)
          {
               throw new NotImplementedException();
          }

           public object GetFormat(Type formatType)
          {
               throw new NotImplementedException();
          }
        }
 
字符字節轉換
  • 在與文件/網絡流的交互中進行編碼/解碼時,往往需要字符與字節間的相互轉換

    • 在用BinaryWriter/StreamWriter將字符串發給文件/網絡流時需要進行編碼

    • 在用BinaryReader/StreamReader從文件/網絡流讀取字符串時需要進行解碼

    • 常用的編/解碼方案時UTF-8和UTF-16,默認為UTF-8

  • 編碼/解碼

    • 使用System.Text.Encoding派生的一個類型的實例來對字符進行編碼/解碼

    • Encoding基類下有幾個靜態屬性,返回從Encoding派生的一個類的實例

      • //
        // 摘要:
        //     Represents a character encoding.
        [NullableAttribute(0)]
        [NullableContextAttribute(1)]
        public abstract class Encoding : ICloneable
        {
           public static Encoding UTF8 { get; }
           public static Encoding UTF7 { get; }
           public static Encoding UTF32 { get; }
           public static Encoding Unicode { get; }
           public static Encoding BigEndianUnicode { get; }
           public static Encoding ASCII { get; }
           public static Encoding Default { get; }
        }
      • 首次請求一個編碼對象時,Encoding類會為請求的編碼方案構造並返回對象,之後再請求相同的編碼對象就會直接返回已構造好的對象

    • 通過GetBytes將字符串轉換成字節數組

      • string str = "Hello World";
        Encoding utf8 = Encoding.UTF8;

        byte[] encoded = utf8.GetBytes(str);
        Console.WriteLine($"Coded: {BitConverter.ToString(encoded)}");
        //Coded: 48 - 65 - 6C - 6C - 6F - 20 - 57 - 6F - 72 - 6C - 64
    • 通過GetString將字節數組轉換成字符串

      • string decoded = utf8.GetString(encoded);
        Console.WriteLine($"Decoded: {decoded}");
        //Decoded: Hello World
字符字節流編碼/解碼
  • 字節流在網絡傳輸中尤其常見,字節流的傳輸通常以「數據塊」的形式傳輸

    • 如先從流中讀取5個字節,再從讀取7個字節

      • 在UTF-16的標準中,每個字符由2個字節構成

      • 因此第一次讀取返回2個字符;第二次讀取返回3個字符

      • 但數據應返回6個字符,因此會出現數據損壞

  • 數據塊的解碼需要從Encoding對象中,通過GetDecoder()得到一個Decoder引用

    • 然後再調用其GetChars或GetCharCount方法進行解碼

    • string str = "Hello World";
      Encoding utf8 = Encoding.UTF8;
      Decoder dc = utf8.GetDecoder();
      //dc.GetChars(數據流);
    • 調用Decoder.GetChars/GetCharCount時,它會盡可能多地解碼字符數組,假如字節數組包含的字節不足以完成一個字符,剩餘的字節會保存到Decoder對象內部

      • 下次調用這些方法時,會利用剩餘的字節再加上新字節數組進行解碼,確保對數據塊的正確解碼

參考書目

  • 《CLR via C#》(第4版) Jeffrey Richter