C#.Net築基-型別系統①基礎

安木夕發表於2024-05-21

image.png

C#.Net的BCL提供了豐富的型別,最基礎的是值型別、引用型別,而他們的共同(隱私)祖先是 System.Object(萬物之源),所以任何型別都可以轉換為Object。


01、資料型別彙總

C#.NET 型別結構總結如下圖,Object是萬物之源。最常用的就是值型別、引用型別,指標是一個特殊的值型別,泛型必須指定確定的型別引數後才是一個正式的型別。

image

1.1、值型別彙總⭐

🔸值型別 Type 說明 示例/備註
byte System.Byte 8 位(1位元組)無符號整數,0到255,
sbyte System.SByte 8 位(1位元組)有符號整數,-128 到 127 不符合 CLS
int System.Int32 32位(4位元組)有符號整型,大概-21-21億
uint System.UInt32 32位(4位元組)無符號整型, 0 到 42億 不符合 CLS
short System.Int16 16 位(2位元組)有符號短整數,-32768 到 32767,
ushort System.UInt16 16 位(2位元組)無符號 短整數, 0 到 65535 不符合 CLS
long System.Int64 64位(8位元組)有符號長整形,19位數
ulong System.UInt64 64位(8位元組)無符號長整形,20位數 不符合 CLS
Int128 Int128 128 位有符號整數,.Net7支援
BigInteger BigInteger 表示任意大小的(有符號)整數,不可變值
整數表示 - 十六進位制:0x/0X字首;二進位制:0b/0B字首 var預設為int
float System.Single 32位(4位元組)單精度浮點數,最多6-7 位小數,-+3.402823E38 字尾f/F
double System.Double 64位(8位元組)雙精度浮點數,最多15-16 位小數,-+1.7*E308 字尾d/D
decimal System.Decimal 128位(16位元組)高精度浮點數,28位小數,-+7.9E28 字尾m/M
bool System.Boolean 8位(1位元組)布林型,只有兩個值:truefalse
char System.Char 16 位(2位元組)單個字元,0 到 65,535的碼值,使用 UTF-16 編碼
enum System.Enum 支援任意整形的(常量)列舉,可以看做是一組整形的常量值集合
Complex Complex 表示一個複數,實部和虛部都為double
Guid Guid 全域性唯一識別符號(16位元組),一出生就是全球唯一的值 Guid.NewGuid()
struct 結構體 是一種使用者自定義的值型別,常用於定義一些簡單(輕量)的資料結構
DateTime DateTime 時間日期,詳見下一章節

  • bool 雖然只需要1位空間,但仍然佔用了1個位元組,是因為位元組是處理器的最小單位。如果有大量的Bool,可用BitArray。
  • 值型別大多都是“不可變的”,就意味著修改會建立新的物件,上面表格中除了自定義的結構體Struct外都是不可變的。
  • CTS(Common Type System)為通用型別系統,為微軟定製的通用型別規範,所有.Net語言都支援(如F#、VB、C#)。不符合CTS就意味著C#獨有。

1.2、引用型別彙總⭐

🔸引用型別 Type 說明 示例/備註
object System.Object .NET 類的頂層基類,萬物之源,包括所有值型別、引用型別 萬物之源
string System.String 字串,引用型別,使用上有點像值型別 恆定性、駐留性
dynamic System.Dynamic 動態型別,編譯時不檢查,可隨意編碼,只在執行時檢查 dynamic = 12
Interface - 嚴格來說並不是“型別”,只是一組契約,可作為引用申明變數 介面契約
Class - 定義一個引用型別,隱式繼承自object 定義類
Delegate System.Delegate 委託型別,詳見後文《解密委託與事件
IEnumerable - 可列舉集合介面,幾乎所有集合型別都實現了該介面
T[] Array 陣列,同上列舉介面,更多參考《.Net中的集合
匿名型別 new{P=v,V=1} 動態申明一個臨時匿名型別例項, 編譯器會建立類
元祖 Tuple 內建的一組包含若干屬性的泛型類,常用(ValueTuple)編譯器支援
recored record 記錄型別,支援class(預設)、struct,其實就是簡化版的型別申明 編譯器建立完整型別

1.3、Object-萬物之源

System.Object是所有型別的根,任何類都是顯式或隱式的繼承於System.Object,包括值型別,所以任何型別都可以轉換為Object

成員 描述
string? ToString () 返回物件的字串,預設返回物件型別名稱,按需重寫。
bool Equals (object? obj) 比較是否與當前(this)相同,引用型別比較引用地址,值型別比較值&型別,可重寫!
int GetHashCode () 獲取當前物件的雜湊碼,用於雜湊集合Dictionary、Hashtable中快速檢查相等性。但不可用於相等判斷,如果重寫了Equals,應同時重寫GetHashCode,確保兩者一致。
Type GetType () 當前例項的準確執行時型別。如果是型別,在可用typeof(T)
protected ~Object (); Object.Finalize解構函式,GC呼叫釋放資源,按需實現。一般配合Dispose(GC.SuppressFinalize )
protected object MemberwiseClone() 淺複製,建立新物件>賦值非靜態欄位,引用型別欄位就是賦值引用地址了。
static bool Equals(Object, Object) 比較相同,內部會呼叫例項的Equals(Object)方法
static bool ReferenceEquals(o1,o2) 比較兩個引用物件是否同一例項,⁉️注意如果用於值型別會被裝箱從而始終false

下面程式碼為.Net中的 System.Object 原始碼

public partial class Object
{
    public Object(){ }
    public virtual string? ToString()
    {
        return GetType().ToString();
    }
    protected internal unsafe object MemberwiseClone();  //物件淺複製
    public virtual bool Equals(object? obj)
    {
        return this == obj;
    }
    public static bool Equals(object? objA, object? objB)
    {
        return objA == objB || (objA != null && objB != null && objA.Equals(objB));
    }
    public static bool ReferenceEquals(object? objA, object? objB)
    {
        return objA == objB;
    }
    public virtual int GetHashCode()
    {
        return RuntimeHelpers.GetHashCode(this);
    }
    ~Object(){}  //終結器
}

02、值型別與引用型別

值型別和引用型別是C#中最重要、最常用的兩種資料型別,兩者是有很多區別的,而且常常和效能有很大關係,因此這是C#開發者必須掌握的基礎知識。

2.1、值型別 VS 引用型別

區別 值型別 ValueType 引用型別 ReferenceType
⭐儲存位置 棧(Stack),也可以稱為執行緒棧 堆(GC Heap),由GC管理
⭐儲存內容 物件在堆上,引用變數在棧上,棧儲存的的是堆上物件的記憶體地址
⭐傳遞方式 值傳遞,引數傳遞時傳遞的是值複製,各回各家 傳遞的是引用(地址),因此是同一個物件,分身代理
裝箱、拆箱 轉換為object、介面時會裝箱、拆箱
預設值default 數字、列舉預設0,bool預設false 預設null
佔用記憶體大小 就值本身的長度,如int為4個位元組 值本身+額外空間(引用物件的標配:TypeHandle、同步塊)
繼承的物件 隱式繼承自System.ValueType 預設繼承自Object
介面、繼承 不支援繼承其他值型別,可繼承介面 支援繼承類、介面
怎麼判斷 Type.IsValueTypeo.GetType().IsValueType IsValueType ==false
生命週期 作用域結束就釋放,或方法結束就釋放了 由GC管理,當物件沒被使用了,GC檢查後標記清除
效能 棧記憶體效能很高 低,需要GC分配記憶體、GC釋放
  • default可以表示任意型別的預設值,編譯時會被賦值。struct的預設值為每個欄位設定預設值。
int x = 100;
int y = x;     //值複製傳遞
string name = "sam";
int[] arr = new int[] { 1, 2, 3 };
var list = arr; //引用地址傳遞,實際指向同一個引用物件,分身幻象

📢 兩者核心區別就是儲存的方式不同,理解這一點非常重要,在變數(欄位)賦值、方法引數傳遞上都是如此。

image

🔸Stack 棧:(執行緒)棧,由作業系統管理,存放值型別、引用型別變數(就是引用物件在託管堆上的地址)。棧是基於執行緒的,也就是說一個執行緒會包含一個執行緒棧,執行緒棧中的值型別在物件作用域結束後會被清理,效率很高。

🔸託管堆(GC Heap):程序初始化後在程序地址空間上劃分的記憶體空間,儲存.NET執行過程中的物件,所有的引用型別都分配在託管堆上,託管堆上分配的物件是由GC來管理和釋放的。託管堆是基於程序的,當然託管堆內部還有其他更為複雜的結構。

關於更多堆疊記憶體資訊,檢視後文《C#的記憶體管理藝術》

📢值型別可使用outref關鍵字,像引用型別一樣傳遞引數地址。兩者對於編譯器是一樣的,都是取地址,唯一區別就是ref引數需要在外面初始化,out引數在方法內部初始化。

2.3、裝箱和拆箱⁉️

因為值型別、引用型別的基類都是Object,因此值型別、引用型別是可以相互轉換的,但這個轉換是有很高成本的,這個過程就是裝箱、拆箱。

int x = 100;     //一個普通的值型別變數
object obj =x;   //裝箱到obj
int y = (int)obj;//拆箱到y

視覺化分析一下這個過程:

🔸裝箱:值型別轉換為引用物件,一般是轉換為System.Object型別,或介面型別。所以“箱子”就是Object引用物件,裝箱的過程:

  • ❶ 在GC堆上申請記憶體,記憶體大小為值型別的大小,再加上額外固定空間(引用型別的標配:TypeHandle和同步索引塊);
  • ❷ 將值(100)複製到分配的記憶體中;
  • ❸ 返回新物件(箱子)的引用地址給變數obj

🔸拆箱:引用型別轉換為值型別,注意,這裡的引用型別只能是被裝箱的引用型別物件。

  • ❶ 檢測操作是否合法,如箱子是否為null,型別是否和待拆箱的型別一致,檢測失敗則丟擲異常InvalidCastException
  • ❷ 把箱子中的值複製到棧上。

image.png

上面三行裝箱、拆箱程式碼的IL程式碼:裝箱box、拆箱unbox是兩個專門的指令。

image.png

由上可知,裝箱會在GC堆上建立一個“箱子”(Object物件)來裝載值,這是裝箱造成極大的效能損失的根本原因,拆箱則把值搬回到棧記憶體上。

  • 只有值型別才會有裝箱、拆箱,引用型別一直都在“箱子”裡。
  • 相對來說裝箱的效能損失更大,原因不難理解,建立引用物件(箱子)的效能開銷更大。

📢在日常開發中,很容易發生隱式裝箱,所以要特別注意,儘量用泛型。如ArrayList、Hashtable 都是面向Object的集合,應該用List<T>Dictionary<TKey, TValue>代替。

ArrayList arr = new ArrayList();
arr.Add(1);      //裝箱
arr.Add(true);   //裝箱
Hashtable ht = new Hashtable();
ht.Add(1,1.2f);  //裝箱了兩次

對比測試裝箱、拆箱的效能影響:

private T Add<T>(T arg1,T arg2) where T:INumber<T>
{
	return arg1+arg2;
}
private int AddWithObject(object x, object y)
{
	return (int)x + (int)y;
}

測試結果比較明顯,裝箱的方法在執行效率、記憶體消耗上都要差很多。

image.png


03、Nullable?可空型別

可空型別可用於值型別、引用型別,他們使用語法類似,不過他們是完全不同的兩種東西。值型別的可空?是一個泛型Nullable<T>型別,而引用型別的?只是一個用於編譯器檢查的語法。

image.png

int? n = null;
string? str = "sam";

可空值型別、引用型別都支援null運算子:

  • Null 條件運算子str?.Length
  • Null 合併賦值,n??=1

3.1、值型別的Nullable<T>

對於值型別,可空值型別表示值型別物件可以為null值,可空值型別T?的本質其實是Nullable<T>,他是一個值型別(結構體)。

int x0 = default;  //預設值為0
int? x1 = default; //預設值值為null
int? x2 = null;
Nullable<int> x3 = null;  //同上

if (x3.HasValue)
{
    Console.WriteLine(x3.Value);
}
int a = x0 + (x3.HasValue? x3.Value : 10);
int b = x0 + x3 ?? 10; //效果同上
  • 簡化的語法為:Type?,示例:int? n;,型別後跟一個問號"?",和引用型別的可空語法一樣。
  • 屬性HasValue判斷是否有值,Value獲取值,如果HasValuefalse時獲取 Value 值會丟擲異常。
  • 轉換:T可隱式轉換為T?,反之則需要顯示轉換。

下面為Nullable<T>的原始碼,是一個簡單的結構體,T被約束為結構體(值型別)。

public struct Nullable<T> where T : struct
{
    //判斷是否有值
	public readonly bool HasValue { get; }
    //獲取值
	public readonly T Value { get; }
    public readonly T GetValueOrDefault()
    public readonly T GetValueOrDefault(T defaultValue)
}

image.png

3.2、可空引用型別T?

引用型別本身的預設值就是null,為了避免一些場景下不必要的 NullReferenceException,就有了可空的引用型別T?,其核心目的就是為提高程式碼的健壯性。可空的引用型別並不是一個“新的型別”,而是一個編譯指令,告訴編譯器這個引用型別變數可能是null,使用時需檢查,未初始化也沒檢查null就使用,編譯器會產生編譯告警,由此來提前發現潛在Bug,提高程式碼健壯性。如果沒加?,則該表示引用物件不會為null

要開啟可空引用型別需要配置啟用才行:

  • 在專案配置中開啟:<Nullable>enable</Nullable>,值disable表示不啟用。
  • 在程式碼檔案中啟用,在檔案頭部加#nullable enable,只對當前程式碼檔案有效。
public int GetLength(string? firstName, string lastName)
{
	var len = firstName.Length; //編譯器會警告 firstName 可能為null
	if (firstName != null)      //加上null判斷就好了
		len += firstName.Length;
	len = firstName!.Length;    //加上!,則忽略檢查
	len += lastName.Length;     //lastName不會為null,沒有告警
	return len;
}

對於上面的示例程式碼,編譯器會認為firstName可能會為null,在使用前必須初始化,或者檢查是否為null。而lastName不會為null,可以直接使用。

📢 消除可空引用型別的編譯告警的方法是結尾加!(null 包容運算子),告訴編譯器這個物件肯定不會為null,別再告警了!

在名稱空間“System.Diagnostics.CodeAnalysis”下還有一些特性,用來輔助程式碼的靜態檢查和編譯器檢查。

[NotNull]  //標記返回值不會為null
private string? FullName => "sam";

//當方法返回false時引數value不會為null。該方法就是string.IsNullOrEmpty的原始碼
public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
{
	if ((object)value != null)
	{
		return value.Length == 0;
	}
	return true;
}

04、型別轉換⭐

資料型別之間是可以相互轉換的,由一個資料型別轉換為另一資料型別,常見的轉換方式:

轉換方式 說明 備註/示例
隱式轉換 轉換是自動的,一般是相容的數值型別之間,編譯時檢查 int n =100; float f = n;
強制顯示轉換 使用強制轉換運算子轉換,(Type)value 值型別編譯時檢查,引用型別執行時檢查,失敗丟擲異常!
裝箱、拆箱 值型別轉換為引用型別object,反之為拆箱 裝箱是隱式的,拆箱需顯示轉換
as顯示轉換 只用於引用型別轉換:value as Type 執行時檢查,失敗返回null
is型別檢查 檢查一個值是否為指定(相容)型別,如果是則轉換 if(obj is float f) {}
型別方法Parse 內建值型別基本都提供Parse、TryParse方法 int.Parse("123")
Convert 靜態類,提供了大量的靜態方法來轉換內建資料型別 Convert.ToInt16(false)
BitConverter 靜態類,各種內建型別和位元組之間的轉換方法 BitConverter.GetBytes(0xff)
XmlConvert 靜態類,提供了各種內建型別和string之前的轉換方法 XmlConvert.ToInt32("1221")
TypeConverter System.ComponentModel 空間下提供的大量型別轉換器 var cc = TypeDescriptor.GetConverter(typeof(Color))
dynamic 動態型別並不算是型別轉換,作為一種特殊方式,執行時檢查 dynamic d = Foo; d.Print();

📢注意

  • 幾乎所有型別都可以隱式轉換為 Object,注意值型別轉換Object會裝箱。
  • 對於值型別,範圍小的型別轉換範圍大的型別大都支援隱式轉換,且不會損失精度,如floatdoubleint轉浮點數。反之則需要強制轉換,可能會損失精度,或溢位。
  • is 語句可用於模式匹配,實現靈活的型別、資料檢查,詳細參考《C#中的模式匹配彙總》。
int a = (int)'a';     // 強制型別轉換
Console.WriteLine(a); // 97

float f = a;          // 隱式型別轉換
Console.WriteLine(f);

object obj = f;         //裝箱,值型別隱式轉換為引用型別
float f2 = (float)obj;  //拆箱
if(obj is float f3)     //is檢查是否為float型別,如果是則轉換值到變數f3
{
    Console.WriteLine(f3);
}
string str = obj as string; //as 轉換失敗,obj是float裝箱
Console.WriteLine(str);     //null

4.1、數值轉換方式彙總

轉換需求 轉換方法 示例
解析十進位制數字 Parse、TryParse int.Parse("1234")double.Parse("123.04")
解析2/8/16進位制數 Convert.To() Convert.ToInt32("F",16)//15
16進位制格式化 ToString("X") 1234.ToString("X6")//0004D2
無損數值轉換 隱式轉換 int n = 100; double d = n;
截斷數值轉換 顯示轉換 T v2 = (T)v1 double d=12.56d; int n = (int)d; //n = 12,直接截斷,不會四捨五入
四捨五入轉換 Convert.To()、Math.Round(d) int n = Convert.ToInt32(d); //n = 13,四捨五入轉換
int n = Math.Round(d) //n = 13,可指定小數位數

05、相等、大小比較

📢 值型別比較的是值(結構體會比較其所有欄位值),引用型別是比較的引用地址!

比較操作 說明
==、!= 相等運算子,其本質是呼叫靜態方法(運算子過載方法)
a.Equals(b) 例項的虛方法(可被重寫),執行時根據實際型別呼叫。
🔸 ①、a不能為null,否則就NullReferenceException了。
🔸 ②、引用型別預設比較引用地址,值型別會遞迴呼叫每一個欄位的Equals方法。
🔸 ③、裝箱的值型別會比較箱子內的值。
Object.Equals(a,b) Object靜態方法,null判斷+a.Equals(b),引數是Object,值型別會裝箱。
Object.ReferenceEquals(a,b) Object靜態方法,只比較引用地址
IEquatable<T> 相等介面方法bool Equals(T? other)
IComparable<T> 大小比較 int CompareTo(T? other),返回一個int值:a.CompareTo(b)// a>b 返回1,a==b 返回 0, a<b 返回 -1
>、< 大小比較運算子,結果應該和上面 IComparable 保持一致
IEqualityComparer<T> 擴充套件的相等比較介面,非泛型版本 IEqualityComparer
StringComparer 提供了用於字串的多種型別的比較器:StringComparer.Ordinal.Compare("h","H")
public interface IComparable<in T>
{
	int CompareTo(T? other);
}
public interface IEquatable<T>
{
	bool Equals(T? other);
}
public interface IEqualityComparer<in T>
{
	bool Equals(T? x, T? y);

	int GetHashCode([DisallowNull] T obj);
}
// System.Object
public static bool Equals(object? objA, object? objB)
{
	if (objA == objB)
	{
		return true;
	}
	if (objA == null || objB == null)
	{
		return false;
	}
	return objA.Equals(objB);
}
  • Equals()方法必須自相等,即 x.Equals(x) 必為true
  • 對於值型別,大多數情況下Equals()方法等效於 ==,只有double.NaN例外,double.NaN 不等於任何物件。而引用型別則不一定了,有些引用型別重寫了Equals()方法,而沒有重寫 == 運算子。
Console.WriteLine(double.NaN == double.NaN);     //False
Console.WriteLine(double.NaN.Equals(double.NaN));//True

Console.WriteLine(object.Equals(1,1));           //True  //裝箱比較值
Console.WriteLine(object.ReferenceEquals(1,1));  //False //裝箱比較引用

StringBuilder sb1 = new StringBuilder("sb");
StringBuilder sb2 = new StringBuilder("sb");
Console.WriteLine(sb1 == sb2);                     //False
Console.WriteLine(object.Equals(sb1,sb2));         //False
Console.WriteLine(object.ReferenceEquals(sb1,sb2));//False
Console.WriteLine(sb1.Equals(sb2));				   //True  //與上面的 object.Equals(sb1,sb2) 不同

上面的 StringBuilder 重新實現了 Equals方法,但不是繼承覆蓋,而是隱式new覆寫實現的,因此只能在 透過 StringBuilder 引用呼叫時才有效。參考:StringBuilder 原始碼

image.png

5.1、自定義相等

⁉️什麼時候需要自定義相等比較?

  • 提高比較速度,多用於自定義結構體。
  • 修改相等比較的語義,基於實際業務需要自定義相等的規則,如System.Url、String.String 都是引用型別,只要字元值相同則相等(== 和 Equals)。

⁉️如何自定義相等比較?

  • 重寫 GetHashCode()Equals() 方法。這兩個一般是一起配對重寫,需注意 二者的一致性。
  • (可選)過載 !===
  • (可選)實現 IEquatable<T> 介面。

📢GetHashCode() 是基類 Object 的一個虛方法,該方法用於獲取一個物件的 Int32 型別的雜湊碼。該雜湊碼只在鍵值結構(Hashtable、HashSet、Dictionary)中使用,用來表示元素的唯一“ID”,用於在雜湊表中快速檢索資料。

GetHashCode()的預設實現:

  • 值型別的雜湊碼 是由每一個欄位的值來計算的,如果有多個欄位則透過一定的規則組合(如異或運算)。
  • 引用型別則基於物件的記憶體地址。

so,如果重寫了Equals() 方法,則一般要重寫GetHashCode(),讓兩者匹配。當然如果不遵守該規則也沒問題,只是在使用雜湊表時可能會出現問題(如效能嚴重下降)。


參考資料

  • .NET型別系統②常見型別
  • C# 文件
  • 《C#8.0 In a Nutshell》
  • .NET面試題解析(02)-拆箱與裝箱
  • .NET面試題解析(01)-值型別與引用型別

相關文章