【C#本質論 十】合式類型(一)重寫Object成員及操作符重載

第一次看到這章的標題有點懵,啥是合式類型,是一種值類型和引用類型之外的類型麼,以前也沒有聽說過呀?其實並不是,合式類型其實說白了就是合適的類型,如何定義類型,如何操作類型才更好,如何創建合適的值類型和引用類型
在這裏插入圖片描述
這一章的內容比較雜,基本上類似於基礎部分的終結之章,回顧下之前學習的章節,1-5章介紹了結構性編程的基礎知識,6-10章來介紹面向對象的內容,加上接下來11章對異常處理的延伸學習後,基本內容部分相當於結束了!

重寫Object的成員

回顧一下萬類始祖的Object所具有的虛方法,除了Finalize方法不能直接調用以外,我們有三個虛方法可以用來重寫,用來比較對象的Equals和GetHashCode,以及用於返回字符串的ToString
在這裏插入圖片描述

重寫ToSring

爲啥要重寫ToString,因爲Object提供的默認ToString方法提供的是對當前類型的完全限定名輸出,這個完全沒有任何意義啊,我輸出這個對象的字符串信息是想知道一些有用的信息,所以一定要重寫。
在這裏插入圖片描述
例如我想輸出這個座標對象的具體座標,就要通過重寫的方式來獲得:
在這裏插入圖片描述
重寫ToString需要注意以下幾條原則:

  • 如需返回有用的、面向開發人員的診斷字符串,就要重寫ToString()
  • 要使ToString()返回的字符串簡短。
  • 不要從ToString()返回空字符串來代表“空”(null)。
  • 避免ToString()引發異常或造成可觀察到的副作用(改變對象狀態)
  • 如果返回值與語言文化相關或要求格式化(例如DateTime),就要重載ToString(string format)或實現IFormattable。
  • 考慮從ToString()返回獨一無二的字符串以標識對象實例。

總而言之就是要返回有用信息並且千萬別在重寫的方法裏拋異常

重寫GetHashCode()

GetHashCode方法是用來獲取和對象對應的哈希碼,有兩種情況必須重寫該方法:

  • 重寫Equals()方法一定要重寫GetHashCode(),否則編譯器會有警告
  • 將類作爲hash表集合的鍵使用的時候也要重寫GetHashCode()。

要獲得良好的性能實現需要參照以下重寫規則(“必須”是指必須滿足的要求,“性能”是指爲了增強性能而需要採取的措施,“安全性”是指爲了保障安全性而需要採取的措施):
)。

  • 必須相等的對象必然有相等的哈希碼(若a.Equals(b),則a.GetHashCode()==b.GetHashCode())。也就是相等的哈希碼是對象相等性的必要不充分條件,對象相等則哈希碼一定相等,哈希碼相等對象不一定相等,還需要其它條件來滿足
  • 必須:在特定對象的生存期內,GetHashCode()始終返回相同的值,即使對象的數據發生了改變。許多時候應緩存方法的返回值,從而確保這一點。
  • 必須GetHashCode()不應引發任何異常;GetHashCode()總是成功返回一個值。
  • 性能哈希碼應儘可能唯一。但由於哈希碼只是返回一個int,所以只要一種對象包含的值比一個int能夠容納得多(這就幾乎涵蓋所有類型了),那麼哈希碼肯定存在重複。一個很容易想到的例子是long,因爲long的取值範圍大於int,所以假如規定每個int值都只能標識一個不同的long值,那麼肯定剩下大量long值沒法標識。
  • 性能可能的哈希碼值應當在int的範圍內平均分佈。例如,創建哈希碼時如果沒有考慮到字符串在拉丁語言中的分佈主要集中在初始的128個ASCII字符上,就會造成字符串值的分佈非常不平均,所以不能算是好的GetHashCode()算法。
  • 性能:GetHashCode()的性能應該優化。GetHashCode()通常在Equals()實現中用於“短路”一次完整的相等性比較(哈希碼都不同,自然沒必要進行完整的相等性比較了)。所以,當類型作爲字典集合中的鍵類型使用時,會頻繁調用該方法
  • 性能兩個對象的細微差異應造成哈希值的極大差異。理想情況下,1 bit的差異應造成哈希碼平均16 bits的差異。這有助於確保不管哈希表如何對哈希值進行“裝桶”(bucketing),也能保持良好的平衡性。
  • 安全性:攻擊者應難以僞造具有特定哈希碼的對象。攻擊手法是向哈希表中填寫大量哈希爲同一個值的數據。如哈希表的實現不高效,就易於受到DOS(拒絕服務)攻擊。

補充說明一點:高效的哈希表實現就是指哈希值可以良好的均勻的隨機分佈。

namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter10.Listing10_02
{
    public struct Coordinate
    {
        public Coordinate(Longitude longitude, Latitude latitude)
        {
            Longitude = longitude;
            Latitude = latitude;
        }

        public Longitude Longitude { get; }
        public Latitude Latitude { get; }


        public override int GetHashCode()
        {
            int hashCode = Longitude.GetHashCode();
            // As long as the hash codes are not equal
            if(Longitude.GetHashCode() != Latitude.GetHashCode())
            {
                hashCode ^= Latitude.GetHashCode();  // eXclusive OR
            }
            return hashCode;
        }

        public override string ToString()
        {
            return string.Format("{0} {1}", Longitude, Latitude);
        }

    }

    public struct Longitude { }
    public struct Latitude { }
}

這裏使用了異或運算,如果a、b兩個值不相同,則異或結果爲1。如果a、b兩個值相同,異或結果爲0。

  • 首先向來自相關類型的哈希碼應用XOR操作符,並確保操作數不相近或相等(否則結果全零)
  • 在操作數相近或相等的情況下,考慮改爲使用移位(bit shift)和加法(add)操作。其他備選的操作符——AND和OR——具有類似的限制,但這些限制會發生得更加頻繁。多次應用AND,會逐漸變成全爲0;而多次應用OR,會逐漸變成全爲1。

這裏的Longitude和Latitude都是隻讀自動屬性,所以值不會變,如果是會發生改變的值,則應該對哈希碼進行緩存,來滿足生命週期內哈希碼的唯一性原則

重寫Equals()

重寫Equal和重寫GetHashCode有一些區別,這要從對象同一性相等的對象值說起:

  • 兩個引用假如引用同一個實例,就說這兩個引用是同一的。object(因而延展到所有派生類型)提供名爲**ReferenceEquals()**的靜態方法來顯式檢查對象同一性
  • 引用同一性只是“相等性”的一個例子。兩個對象實例的成員值部分或全部相等,也可以說它們相等

也就是同一個引用只是對象相等的一部分例子,兩個對象實例的成員值部分或全部相等,也可以說它們相等。
在這裏插入圖片描述
例如重寫Equals方法後,就可以認爲對象相等:

public class Program
    {
        public static void Main()
        {
            TML tml1 = new TML("PV", "1000", "09187234");
            TML tml2 = tml1;
            TML tml3 = new TML("PV", "1000", "09187234");

            // 對象是不是引用同一
            if (!TML.ReferenceEquals(tml1, tml2))
            {
                throw new Exception("serialNumber1 does NOT " + "reference equal serialNumber2");
            }
            // 不引用同一總相等吧
            else if (!tml1.Equals(tml2))
            {
                throw new Exception("serialNumber1 does NOT equal serialNumber2");
            }
            else
            {
                Console.WriteLine(
                    "serialNumber1 reference equals serialNumber2");
                Console.WriteLine(
                    "serialNumber1 equals serialNumber2");
            }

            // 對象是不是引用同一
            if (TML.ReferenceEquals(tml1, tml3))
            {
                throw new Exception("serialNumber1 DOES reference " + "equal serialNumber3");
            }
            // 不引用同一總相等吧
            else if (!tml1.Equals(tml3) ||tml1!= tml3)
            {
                throw new Exception("serialNumber1 does NOT equal serialNumber3");
            }

            Console.WriteLine("serialNumber1 equals serialNumber3");
        }
    }

    public class TML
    {
        public TML(string name, string price, string number)
        {
            Name = name;
            Price = price;
            Number = number;
        }

        public string Name { get; }
        public string Price { get; }

        public string Number { get; }
    }

輸出如下:

serialNumber1 reference equals serialNumber2
serialNumber1 equals serialNumber2
serialNumber1 equals serialNumber3

其中serialNumber1 和serialNumber2是引用同一,serialNumber1 和serialNumber3是重寫Equals方法和!=操作符後的相等性驗證通過,這個驗證接下來會說到。注意這裏的serialNumber1 和serialNumber3相等需要的場景也有很多,很多時候可以用於查重,如果不通過重寫的方式驗證相等則只能認定引用同一纔是相等,這樣通過不同方式創建的數據就都能逃過查重檢驗了。這裏需要注意的兩點:

  • 只有引用類型才能使用ReferenceEquals方法判斷,值類型的調用永遠是false,因爲值類型要調用該方法一定要裝箱爲object,而各自裝箱產生的引用一定不是同一個。
  • Object.Equals()的實現只是簡單調用了一下ReferenceEquals方法,所以用處很有限。大多數情況下需要重寫。

瞭解了引用同一性和想等性我們來看看重寫Equals的步驟吧:

  1. 檢查是否爲null--------不爲null才能繼續哦,否則沒有比較的必要
  2. 如果是引用類型,就檢查引用是否相等------引用同一則一定相等
  3. 檢查數據類型是否相同
  4. 調用一個指定了具體類型的輔助方法,它的操作數是具體要比較的類型而不是object(例如代碼清單10.5中的Equals(Coordinate obj)方法)
  5. 可能要檢查哈希碼是否相等來短路一次全面的、逐字段的比較---------相等的兩個對象不可能哈希碼不同,哈希碼不同則一定不等,但是有時候散列不均勻或者沒有緩存,則可能導致返回的hash值並非獨一無二,所以不能依賴它判斷亮哥對象是否相等。
  6. 如基類重寫了Equals(),就檢查base.Equals()
  7. 比較每一個標識字段(關鍵字段),判斷是否相等
  8. 重寫GetHashCode()
  9. 重寫==和!=操作符(參見下一節)

可以通過如下代碼實現來驗證步驟:

namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter10.Listing10_05
{
    using System;

    public class Program
    {
        public static void Main()
        {
            //...

            Coordinate coordinate1 =
                new Coordinate(new Longitude(48, 52),
                                new Latitude(-2, -20));

            // Value types will never be reference equal
            if(Coordinate.ReferenceEquals(coordinate1,
                coordinate1))
            {
                throw new Exception(
                    "coordinate1 reference equals coordinate1");
            }

            Console.WriteLine(
                "coordinate1 does NOT reference equal itself");
        }
    }

    public struct Coordinate : IEquatable<Coordinate>
    {
        public Coordinate(Longitude longitude, Latitude latitude)
        {
            Longitude = longitude;
            Latitude = latitude;
        }

        public Longitude Longitude { get; }
        public Latitude Latitude { get; }

        public override bool Equals(object obj)
        {
            // STEP 1: Check for null
            if (obj == null)
            {
                return false;
            }
            // STEP 3: Equivalent data types
            if (this.GetType() != obj.GetType())
            {
                return false;
            }
            return Equals((Coordinate)obj);
        }

        public bool Equals(Coordinate obj)
        {
            // STEP 1: Check for null if a reference type
            // (e.g., a reference type)
            // if (obj == null)
            // {
            //     return false;
            // }

            // STEP 2: Check for ReferenceEquals if this 
            // is a reference type.
            // if ( ReferenceEquals(this, obj))
            // {
            //   return true;
            // }

            // STEP 4: Possibly check for equivalent hash codes.
            // if (this.GetHashCode() != obj.GetHashCode())
            // {
            //    return false;
            // } 

            // STEP 5: Check base.Equals if base overrides Equals().
            // System.Diagnostics.Debug.Assert(
            //     base.GetType() != typeof(object) );
            // if ( !base.Equals(obj) )
            // {
            //    return false;
            // } 

            // STEP 6: Compare identifying fields for equality
            // using an overload of Equals on Longitude
            return ((Longitude.Equals(obj.Longitude)) &&
                (Latitude.Equals(obj.Latitude)));
        }

        // STEP 7: Override GetHashCode
        public override int GetHashCode()
        {
            int hashCode = Longitude.GetHashCode();
            hashCode ^= Latitude.GetHashCode(); // Xor (eXclusive OR)
            return hashCode;
        }
        public static bool operator ==(
            Coordinate leftHandSide,
            Coordinate rightHandSide)
        {
            return (leftHandSide.Equals(rightHandSide));
        }

        public static bool operator !=(
            Coordinate leftHandSide,
            Coordinate rightHandSide)
        {
            return !(leftHandSide.Equals(rightHandSide));
        }
    }

    public struct Longitude
    {
        public Longitude(int x, int y) { }
    }

    public struct Latitude
    {
        public Latitude(int x, int y) { }
    }
}

該實現的前兩個檢查很容易理解。但注意如果類型密封,步驟3可以省略,步驟4~6在Equals()的一個重載版本中進行,它獲取Coordinate類型的對象作爲參數。這樣在比較兩個Coordinate對象時,就可完全避免執行Equals(object obj)及其GetType()檢查。要注意如下設計規範:

  • 要一起實現GetHashCode()、Equals()、==操作符和!=操作符,缺一不可
  • 要用相同算法實現Equals()、==和!=
  • 避免在GetHashCode()、Equals()、==和!=的實現中引發異常
  • 避免在可變引用類型(也就是Object)上重載相等性操作符(如重載的實現速度過慢,也不要重載)。
  • 要在實現IComparable時實現與相等性相關的所有方法。

說了這麼多,實際上重寫Equal就是爲了達到標識數據相等的目的。這裏爲啥不用GetHashCode,因爲這裏沒有緩存哦,所以不能百分百保證相等的對象就一定返回相等的hash,有可能因爲緩存沒有處理好導致其不等,實際上卻是相等的。

用元組重寫GetHashCode()和Equals()

其實GetHashCode()和Equals()的主要作用就是克服Object呆板簡單的判斷,但是實現起來卻很繁瑣,需要對所有關鍵標識數據進行操作。對於Equals(Coordinate coordinate),可將每個標識(關鍵)成員合併到一個元組中,並將它們和同類型的目標實參比較:

    public class TML : IEquatable<TML>
    {
        public TML(string name, string price, string number)
        {
            Name = name;
            Price = price;
            Number = number;
        }

        public string Name { get; }
        public string Price { get; }

        public string Number { get; }

        public bool Equals(TML tml)
        {
            return (Name, Price, Number).Equals((tml.Name, tml.Price, tml.Number));
        }

        public override int GetHashCode()
        {
            return (Name, Price, Number).GetHashCode();
        }
    }

使用元組,所有的底層實現都由元組搞定,只需要標識用來比較的關鍵成員信息就行了。

操作符重載

實現操作符的過程稱爲操作符重載,不僅僅包括==和!=,還支持一些其它操作符,當然在使用的時候需要注意以下兩點,防止出現誤操作:

  • 賦值運算符=不支持重載
  • 重載的操作符不能通過IntelliSense呈現,也就是不能智能提示。
  • ==默認也只是執行引用相等性檢查,所以爲了保證和Equals的同一性,一定也要重寫該操作符!

對於==和!=操作符而言,其操作行爲可以直接委託給Equals:

==和!=重載

 public sealed class ProductSerialNumber
    {
        public ProductSerialNumber(
            string productSeries, int model, long id)
        {
            ProductSeries = productSeries;
            Model = model;
            Id = id;
        }

        public string ProductSeries { get; }
        public int Model { get; }
        public long Id { get; }

        public bool Equals(ProductSerialNumber obj)
        {
            return ((obj != null) && (ProductSeries == obj.ProductSeries) && (Model == obj.Model) && (Id == obj.Id));
        }

        public static bool operator ==(
            ProductSerialNumber leftHandSide,
            ProductSerialNumber rightHandSide)
        {
            if (ReferenceEquals(leftHandSide, null))
            {
                return ReferenceEquals(rightHandSide, null);
            }

            return (leftHandSide.Equals(rightHandSide));
        }

        public static bool operator !=(
            ProductSerialNumber leftHandSide,
            ProductSerialNumber rightHandSide)
        {
            return !(leftHandSide == rightHandSide);
        }
    }

這裏需要注意的是,一定不要用相等性操作符執行空檢查(leftHandSide==null)。否則會遞歸調用方法,造成只有棧溢出纔會終止的死循環。相反,應調用ReferenceEquals()檢查是否爲空。

+和-重載

定義兩個對象之間的+和-實際上也就是定義其關鍵數據的+和-:

 public struct Coordinate
    {
        public Coordinate(Longitude longitude, Latitude latitude)
        {
            Longitude = longitude;
            Latitude = latitude;
        }

        public static Coordinate operator +(
            Coordinate source, Arc arc)
        {
            Coordinate result = new Coordinate(
                new Longitude(
                    source.Longitude + arc.LongitudeDifference),
                new Latitude(
                    source.Latitude + arc.LatitudeDifference));
            return result;
        }

        public static Coordinate operator -(
            Coordinate source, Arc arc)
        {
            Coordinate result = new Coordinate(
                new Longitude(
                    source.Longitude - arc.LongitudeDifference),
                new Latitude(
                    source.Latitude - arc.LatitudeDifference));
            return result;
        }
  }

轉型操作符

怎麼將值類型轉換爲一個不相干的引用類型呢?或者將值類型轉換爲一個不相干的結構,這需要定義一個轉換器,例如轉換double類型和高度類型Latitude:

 public struct Latitude
    {
        public Latitude(double decimalDegrees)
        {
            DecimalDegrees = Normalize(decimalDegrees);
        }

        public double DecimalDegrees { get; }

        // ...

        public static implicit operator double(Latitude latitude)
        {
            return latitude.DecimalDegrees;
        }
        public static implicit operator Latitude(double degrees)
        {
            return new Latitude(degrees);
        }

        private static double Normalize(double decimalDegrees)
        {
            // here you would normalize the data
            return decimalDegrees;
        }
    }

說白了就是通過方法來換值,但是有一個需要注意的就是轉換操作符implicit operator(隱式轉換)explicit operator(顯式轉換),和之前的規範一樣,如果判斷是有損轉換,一定聲明爲顯式的,提醒操作者可能的精度丟失

小小的總結一下,其實本章這兩部分內容都是圍繞着優化現有Object以及C#提供的操作符的優化,大多數時候其它封裝類都定義好了這些,但是我們需要知道,轉換是怎麼做的,有什麼好的方式。明白原理!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章