從String類型發散想到的一些東西

值類型 引用類型

值類型表示存儲在棧上的類型,包括簡單類型(int、long、double、short)、枚舉、struct定義;
引用類型表示存在堆上的類型,包括數組、介面、委託、class定義;
string 是引用類型

字元特殊性

  • 不可變性。字元串創建後,重新賦值的話,不會更新原有值,而是將引用地址更新到一個新的記憶體地址上。

  • 留存性。.NET運行時有個字元串常量池的概念,在編譯時,會將程式集中所有字元串定義集中到一個記憶體池中,新定義的字元串會優先去常量池中查看是否已存在,如果存在,則直接引用已存在的字元串,否則會去堆上重新申請記憶體創建一個字元串。
    下面是關於字元串的一些單元測試,仔細觀察下各個不同:

      [Fact]
      public void Base_Test()
      {
          string a = "abc";
          string b = "abc";
          //字元串的留存性,初始化後會放入常量池,b直接引用a的對象
          Assert.True(string.ReferenceEquals(a, b));
    
          string c = new String("abc");
          string d = new String("abc");
          //直接new的話,會重新分配記憶體
          Assert.False(string.ReferenceEquals(c, d));
          Assert.False(string.ReferenceEquals(a, c));
    
          string e = "abc";
          //這裡e還是使用字元串的留存性,且使用的還是a的地址。證明c分配的記憶體引用並沒有放入常量池替換
          Assert.True(string.ReferenceEquals(a, e));
          Assert.False(string.ReferenceEquals(c, e));
    
          string f = "abc" + "abc";
          string g = a + b;
          string h = "abcabc";
          //f在編譯期間確定,實際還是從常量池中獲取
          //IsInterned 表示從常量池中獲取對應的字元串,獲取失敗返回null
          //a+b實際上是發生了字元串組合運算,內部重新new了一個新的字元串,所以f,g引用地址不同
          Assert.False(string.ReferenceEquals(f, g));
          Assert.True(string.ReferenceEquals(string.IsInterned(f), h));
          Assert.True(string.ReferenceEquals(f, h));
      }
    

Stringbuilder

字元串拼接是一個非常耗資源的操作,例如 string a="b"+"c" ,實際上創建了3個字元串”b”、”c”、”bc”。所以在這個時候就需要StringBuilder來專門執行字元串拼接操作了。
那麼StringBuilder是如何實現的呢?
實際上StringBuilder內部維護了一個char數組,所有的appned類的操作都是將字元串轉化為char存入數組。最後ToString()的時候才去組裝string,減少了大量中間string的創建,是非常高效的字元串組裝工具。
StringBuilder內部還有一個 Capacity 屬性,用於定義數組的初始容量,默認值為25。超過容量會觸發擴容操作。所以在實際操作中,如果我們能預估到拼接字元串的長度,在定義StringBuilder給 Capacity 屬性附上一個合理的值,將會有更加高效的性能。

equals ==

  • equals:比較字元串的值
  • ==:比較字元串的引用地址是否相同

首先有個前提,我們所看到的equals,==,來自於System.Object對象,幾乎所有的原生對象都對其進行了重寫,才構成了我們目前的認知。重寫equals必須重寫GetHashCode。官方給出重寫的實現約定如下:

Equals每個實現都必須遵循以下約定:

  • 自反性(Reflexive): x.equals(x)必須返回true.
  • 對稱性(Symmetric): x.equals(y)為true時,y.equals(x)也為true.
  • 傳遞性(Transitive): 對於任何非null的應用值x,y和z,如果x.equals(y)返回true,並且y.equals(z)也返回true,那麼x.equals(z)必須返回true.
  • 一致性(Consistence): 如果多次將對象與另一個對象比較,結果始終相同.只要未修改x和y的應用對象,x.equals(y)連續調用x.equals(y)返回相同的值l.
  • 非null(Non-null): 如果x不是null,y為null,則x.equals(y)必須為false

GetHashCode:

  • 兩個相等對象根據equals方法比較時相等,那麼這兩個對象中任意一個對象的hashcode方法都必須產生同樣的整數。
  • 在我們未對對象進行修改時,多次調用hashcode使用返回同一個整數.在同一個應用程式中多次執行,每次執行返回的整數可以不一致.
  • 如果兩個對象根據equals方法比較不相等時,那麼調用這兩個對象中任意一個對象的hashcode方法,不一同的整數。但不同的對象,產生不同整數,有可能提高散列表的性能.

請慎重重寫Equals和GetHashCode!!重寫Equals方法必須要重寫GetHashCode!!

關於equals方法參數 StringComparison

public enum StringComparison
{
    //
    // 摘要:
    //     使用區分區域性的排序規則和當前區域性比較字元串。
    CurrentCulture = 0,
    //
    // 摘要:
    //     通過使用區分區域性的排序規則、當前區域性,並忽略所比較的字元串的大小寫,來比較字元串。
    CurrentCultureIgnoreCase = 1,
    //
    // 摘要:
    //     使用區分區域性的排序規則和固定區域性比較字元串。
    InvariantCulture = 2,
    //
    // 摘要:
    //     通過使用區分區域性的排序規則、固定區域性,並忽略所比較的字元串的大小寫,來比較字元串。
    InvariantCultureIgnoreCase = 3,
    //
    // 摘要:
    //     使用序號(二進位)排序規則比較字元串。
    Ordinal = 4,
    //
    // 摘要:
    //     通過使用序號(二進位)區分區域性的排序規則並忽略所比較的字元串的大小寫,來比較字元串。
    OrdinalIgnoreCase = 5
}

通常情況下最好使用 Ordinal或者OrdinalIgnoreCase,性能上最為高效。
除非有特殊的需要,不要使用 InvariantCulture或者InvariantCultureIgnoreCase,因為它要考慮所有Culture的字元轉化對比情況,性能是極差的。
CurrentCulture和CurrentCultureIgnoreCase由於只有本地Culture對比,所以性能還可以接受。

參數傳遞

首先關於參數的存儲,參數是存在棧上的。傳遞參數時,會將對象的「值」在棧copy一份,然後將副本的值傳給方法。對象參數的傳遞分為兩種 「值傳遞」和「引用傳遞」。(注意這裡的引號

  • 值傳遞。默認的參數傳遞都是這種方式。會將對象的值在棧copy一份,然後將複製集的值傳給方法。這裡的值對於 值類型來說,即為對象副本的值。對於引用類型來說,即為對象在堆上的地址。
  • 引用傳遞。可以通過 ref out 關鍵字實現。對於值類型,會直接傳入原對象在棧上的引用。對於引用類型,會傳入原有對象的堆地址的引用。

這裡string雖然是引用類型,但是產生的效果缺和值類型參數傳遞一樣的。大家參考上面關於string的特性思考下原因。

靜心慢慢回味下列單元測試

    [Fact]
    public void Base_Test()
    {
        //引用類型參數
        TestClass s = new TestClass();
        s.Tag = "abc";

        TestMethod m = new TestMethod();
        m.ReNew(s);
        //參數s 實際是對象 s的 地址拷貝。兩者在棧上不同,但是指向的堆地址相同
        //在ReNew方法中 "參數s" 重新指向了一個新的對象,但是不影響舊的對象s
        Assert.True(string.Equals("abc", s.Tag));

        m.Change(s, "123");
        //Change方法是直接修改 參數s 指向的堆對象內的欄位數據,所有對象s欄位也發生了變化
        Assert.True(string.Equals("123", s.Tag));

        m.ReNew2(ref s);
        //注意和ReNew的區別,因為是ref 引用傳遞,所有原對象引用地址指向了新new的對象地址
        Assert.False(string.Equals("abc", s.Tag));
        Assert.True(string.Equals("cba", s.Tag));

        //值類型參數
        int val = 100;
        //Change方法內部改變了val的值,但不影響val原來的值
        m.Change(val);
        Assert.True(val == 100);

        m.Change(out val);
        //使用out標記,改變了val原來的值
        Assert.True(val == 123);
    }
}

public class TestMethod
{
    public void ReNew(TestClass c)
    {
        c = new TestClass() { Tag = "cba" };
    }

    public void ReNew2(ref TestClass c)
    {
        c = new TestClass() { Tag = "cba" };
    }

    public void Change(TestClass c, string tag)
    {
        c.Tag = tag;
    }

    public void Change(int a)
    {
        a = 123;
    }
    public void Change(out int a)
    {
        a = 123;
    }
}

public class TestClass
{
    public string Tag { get; set; }
}

ref out

ref out都是用來標識通過引用傳遞方式傳參。不同的是,ref 需要參數在方法調用前初始化,out 則要求參數在方法體內賦值。

裝箱 拆箱

裝箱,即值類型轉化為引用類型;從記憶體存儲角度,將值類型從棧的值copy,然後放到堆上,並附加額外的引用類型功能記憶體佔用(如類型指針、同步塊索引等)。
拆箱,即引用類型轉化為值類型。從記憶體存儲角度,獲取引用類型的指針,得到值copy,放到棧上。
從性能角度上,裝箱的性能損耗>拆箱的性能損耗。在實際運用中,我們要盡量避免裝箱和拆箱,這也是泛型類型出現後,一個非常大的作用就是避免了裝箱拆箱的大量操作。