
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)-值类型与引用类型
发表评论