C#.Net的BCL提供了豐富的型別,最基礎的是值型別、引用型別,而他們的共同(隱私)祖先是 System.Object
(萬物之源),所以任何型別都可以轉換為Object。
01、資料型別彙總
C#.NET
型別結構總結如下圖,Object
是萬物之源。最常用的就是值型別、引用型別,指標是一個特殊的值型別,泛型必須指定確定的型別引數後才是一個正式的型別。
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位元組)布林型,只有兩個值:true 、false |
|
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; //引用地址傳遞,實際指向同一個引用物件,分身幻象
📢 兩者核心區別就是儲存的方式不同,理解這一點非常重要,在變數(欄位)賦值、方法引數傳遞上都是如此。
🔸Stack 棧:(執行緒)棧,由作業系統管理,存放值型別、引用型別變數(就是引用物件在託管堆上的地址)。棧是基於執行緒的,也就是說一個執行緒會包含一個執行緒棧,執行緒棧中的值型別在物件作用域結束後會被清理,效率很高。
🔸託管堆(GC Heap):程序初始化後在程序地址空間上劃分的記憶體空間,儲存.NET執行過程中的物件,所有的引用型別都分配在託管堆上,託管堆上分配的物件是由GC來管理和釋放的。託管堆是基於程序的,當然託管堆內部還有其他更為複雜的結構。
關於更多堆疊記憶體資訊,檢視後文《C#的記憶體管理藝術》
📢值型別可使用
out
、ref
關鍵字,像引用型別一樣傳遞引數地址。兩者對於編譯器是一樣的,都是取地址,唯一區別就是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
。 - ❷ 把箱子中的值複製到棧上。
上面三行裝箱、拆箱程式碼的IL程式碼:裝箱box
、拆箱unbox
是兩個專門的指令。
由上可知,裝箱會在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;
}
測試結果比較明顯,裝箱的方法在執行效率、記憶體消耗上都要差很多。
03、Nullable?可空型別
可空型別可用於值型別、引用型別,他們使用語法類似,不過他們是完全不同的兩種東西。值型別的可空?
是一個泛型Nullable<T>
型別,而引用型別的?
只是一個用於編譯器檢查的語法。
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
獲取值,如果HasValue
為false
時獲取 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)
}
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
會裝箱。 - 對於值型別,範圍小的型別轉換範圍大的型別大都支援隱式轉換,且不會損失精度,如
float
轉double
,int
轉浮點數。反之則需要強制轉換,可能會損失精度,或溢位。 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 原始碼。
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)-值型別與引用型別