今天我们来探索一下老生常谈的Struct
和Class
的异同.
相信大家或多或少在面试中被问到这两者的差别, 或者自己写代码的时候也会有一些疑惑, 如:
Struct
和Class
的区别?Struct
, 什么情况下用Class
?Struct
用new
关键字和不用有什么区别?Struct
, 那么这个字段是在堆上还是栈上?Struct
的默认值是什么?今天我们就从它们两个的实质来探索相同与不同之处, 让大家不再迷惑.
Struct
和Class
的差别, 主要来自于struct
是值类型,class
是引用类型. 那么我们先对值类型和引用类型做简单的了解.
值类型就是大小确定, 语言内置的大部分基本类型, 比如整型, 布尔, 浮点, byte, char, 枚举, 结构体.
这个所谓大小确定是什么意思呢? 就是你声明的类型, 本身固定多少就是多少, 而结构体是基本数值类型的结合, 所有字段都是固定大小, 加起来就是结构体的大小.
当然, 如果结构体的某个字段是引用类型, 而引用类型的变量本身的大小也是固定的, 所以也没有违背上述规则.
这里还有一点要注意的就是, 结构体有所谓字节对齐的概念, 也就是说, 如果有两个字段, 一个是4字节, 一个是1字节, 那么1字节的那个字段会强制被提升到4字节, 那么整个结构体是8字节. 如果字段都是1字节的, 那么简单加起来就可以了. 这个话题我们会单开一篇文章来说明, 这里只是简单提一下.
值类型的另一大特点是, 内存中直接存储的就是数据本身, 什么意思呢?
如果我们使用一个变量来表示一个整型, 那么变量本身的内存里存的就是这个整型值. 如果是引用类型, 这个变量本身的内存里存的其实是真实数据的地址, 我们需要"解开"这个地址, 才能真正定位数据.
我们可以简单的理解为, 值类型的变量本身就是那个数值, 每一个变量就对应了一个数值, 给同一个变量赋值, 只是将其本身地址中存放的数值更新.
作为值类型的结构体, 有一些特殊的点需要关注.
我们看到过如下的结构体实例化和初始化:
class ClassA
{
}
struct StructA
{
public int i;
public ClassA ca;
}
StructA a;
StructA b = default(StructA);
StructA c = new StructA();
StructA d = c;
a.i = 0;
int ia;
int ib = default(int);
int ic = new int();
int id = ic;
我们知道, 值类型的变量只要声明, 那么其内存就已经存在, 只是其内容是未知的, 所以需要初始化其内容, 基础类型如整型, 可以直接使用数值来初始化, 而结构体的初始化, 需要将其所有的字段一一初始化.
上面我们分别使用结构体和整型作为示例, 分别列出几种情况, 并说明其差别.
先来说整型:
int ia; // 只声明了, 没有初始化内容, 不能使用, 只要使用就编译不过
int ib = default(int); // 声明了一个变量, 并使用整型的默认值(0)来初始化
int ic = new int(); // 声明了一个变量, 并调用了其默认构造函数, 在其中使用了整型的默认值(0)来初始化
int id = ic; // 声明了一个变量, 并使用了另一个变量的值来初始化
然后说结构体:
StructA a; // 只声明了, 没有初始化内容, 不能使用, 只要使用就编译不过
StructA b = default(Struct); // 声明了一个变量, 并使用StructA的默认值(每个字段都是其类型的默认值)来初始化
StructA c = new StructA(); // 声明了一个变量, 并调用了其默认构造函数, 在其中使用StructA的默认值(每个字段都是其类型的默认值)来初始化
StructA d = c; // 声明了一个变量, 然后使用另一个结构体的对象的所有的字段值来初始化
a.i = 0; // 可以使用这个字段, 不能使用其他字段, 也不能单独使用变量本身, 编译不过
从上面的例子中, 我们可以获知:
default
关键字获取new
关键字来实例化和初始化, 至于是分配在栈上还是在堆上, 要看其声明的位置(这点和C/C++不同)引用类型特指那些不能直接存放在变量本身所在内存中的类型, 而是需要通过地址中转的类型, 真实存放地址在另外的地方. 相信有了值类型的概念, 两相对比之下会比较好理解一点.
在C#中, 引用类型有类, 数组, 字符串等等.
引用类型是放在另一个地方, 变量存放的只是其地址, 需要通过地址中转, 而在在C#中, 引用类型总是分配在堆上的(在C/C++却不总是这样), 也可以在不安全的代码中, 可以将基础类型的数组申请在栈上生成(其他引用类型的数组不允许), 比如:
int* i = stackalloc int[2];
引用类型的使用分为两个部分, 分为变量+引用对象, 变量本身只是一个存放引用对象地址的内存, 大小固定, 可以在栈上也可以在堆上, 引用对象总是在堆上.
声明引用类型的变量, 只是在内存中声明一个类型的引用变量, 只是用于存放引用对象的地址, 默认为null
.
两个引用类型变量的相互赋值, 只是将指向的对象地址值来覆盖了变量的内存, 实质只是将两个变量指向了同一个引用对象.
通过其中一个变量来修改了引用对象, 通过另一个变量来访问, 也会发生修改, 因为他们实质上是在操作同一个对象.
我们常常所说的函数传参, 通过值传递还是引用传递, 说的其实是传递的是对象本身, 还是说传递的是对象的地址. 如果是本身, 往往是复制了一份, 修改不影响实参, 如果是地址, 往往是改的引用对象, 修改会影响实参.
值类型还是引用类型的第一个区别就是在内存分配时, 到底是分配在堆上还是栈上.
那么我们首先来简单认识一下什么是栈, 什么是堆.
栈这种数据结构, 想必大家都了解过(不了解的可以参考其他文章), 我们这里所说的栈实质是: 线程栈.
我们都知道, 程序运行起来, 至少会有一个主线程, 每个线程都有一个各自独立的栈空间, 我们所运行的代码, 就是这样一行一行Push到栈上, 运行完毕后又Pop出栈, 最后将栈空间释放. 每个函数也有各自的函数栈(依然位于线程栈上), 在函数退出后清空.
比如以下的代码:
int test()
{
int j = 0;
return test();
}
void main()
{
int i = 0;
int r = test();
return;
}
可能不太准确, 大概理解就行.
我们上面介绍的值类型作为局部变量(函数内部申请, 或者函数的形参, 或者函数内申请的结构体的字段)时, 就是分配在函数栈上, 当函数执行完毕后, 函数栈的空间被释放, 这些局部变量的内容也就被释放了, 这些变量小巧, 迅速申请, 迅速使用, 迅速释放, 不会太多造成内存压力.
这样的栈结构, 访问数据非常快(因为地址是连续的), 但是内存大小是受限的, 一旦分配的对象太大或者函数层级太多(因为函数还没结束, 过程中的函数栈空间无法被释放), 就容易栈溢出导致程序崩溃(典型的就是函数递归调用的无限循环), 相信大家或多或少都遇到过吧?
相比于栈来说, 堆的地址不是连续的, 这是一种高级的数据结构, 大部分使用树来实现, 感兴趣的同学可以参考: 最大最小堆, 优先队列等.
我们可以将堆理解为一个个不同大小的内存块组成的结构, 如图所示:
可以看到, 堆的内存是很灵活的, 可大可小, 可咸可甜, 我们完全可以自由自在的控制(才怪!).
如果使用C/C++, 我们首先要克服的问题就是内存管理, 准确的说就是堆内存的管理, 申请多大, 什么时候释放, 都需要我们自己保证, 一旦你使用不当, 轻则造成内存碎片, 运行一段使用后找不到可用内存可以分配, 重则访问了不该访问的内容(你懂的), 然后被操作系统干掉!
当然, 在C#中使用则轻松了很多, 我们没法直接操作堆内存, 而且CLR(通用语言运行时, C#的运行环境)在合适的实际还会对已经分配的内存做移动和回收, 尽可能的避免碎片的产生. 我们只要申请和使用, 完事儿后的清理交给CLR就行.
对于我来说, C#就是我这种C/C++手残党的福音, 至于说性能? 你说啥, 我不懂, 你觉得我写的C/C++代码性能能有多好? …
其实上面已经给出了答案:
所以struct
到底分配在哪儿?
如果是局部的, 那么就是在栈上, 如果是引用类型的字段, 或者元素(比如数组元素), 那么就是在堆上.
对于值类型来说, 不管是在栈上, 还是在堆上分配, 一个变量就代表一块内存, 变量之间赋值都只是在内存中进行值的替换而已.
对于引用类型来说, 一个变量的内存和其引用对象本身, 是两块内存, 在变量之间赋值, 替换的是变量本身的内存中的引用对象内存地址.
Struct
和Class
的异同有了值类型和引用类型的基础知识, 我们可以开始探讨Struct
和Class
的不同点了.
他们之间的不同点, 大部分都来自于他们一个是值类型, 一个是引用类型.
struct
的两个对象的比较, 是其中每个字段的值比较, 而class
的比较是其引用对象的地址比较struct
不能定义默认的构造器(无参构造函数), 只能定义带参构造函数, 且带参构造函数还必须初始化所有的字段, class
则不需要struct
没有析构函数struct
是密封的, 不能作为基类, 也不能继承类(除了隐式继承System.ValueType), 但可以实现接口, 所以也不接受virtual, protected, sealed
等关键字struct
使用new
初始化对象, 会调用指定的构造函数, 但是不影响在堆还是栈上分配, class
的new
关键字也会调用构造函数, 但是肯定是分配在堆上struct
的字段不允许在声明时初始化, 但是属性可以, class
是两者都可以struct
可以作为可空类型(Nullable
)的元素, class
不可以上面的差别看上去很多, 其实都可以从值类型和引用类型的差异上来推断得到.
C#在底层处理值类型时, 是先将其当做结构体处理, 最终当做class
来处理, 比如常见的一些基础类型: int, double
, 都只是一些结构体的别名:Int32, Double
. 我们可以在值类型上F12
跳转, 看到对应的结构体, 所以那些值类型还有能带一些方法, 比如:
3.ToString(); // 这是可以正常使用的
也就是说, 常见的基础值类型其实是结构体, 然后这些结构体又隐式继承System.ValueType
, 这本身是一个抽象类, 我们可以通过结构体的ToString
方法, 然后在override
关键字来跳转到这个类, 而所有的类又隐式继承System.Object
, 也可以通过override
跳转:
//
// 摘要:
// 表示 32 位有符号整数。 若要浏览此类型的.NET Framework 源代码,请参阅 Reference Source。
[ComVisible(true)]
public struct Int32 : IComparable, IFormattable, IConvertible, IComparable, IEquatable
{
// ...
public override string ToString();
}
//
// 摘要:
// 为值类型提供基类。
[ComVisible(true)]
public abstract class ValueType
{
// ...
protected ValueType();
// ...
public override string ToString();
}
public abstract class Object
{
// ..
public override string ToString();
}
所以说, 在C#中, 值类型本身还是当做引用类型在处理, 只是我们作为上层的应用程序员不需要关注这个而已.
也是因为这点, 所以对于值类型来说, 类类型拥有的一些性质, 值类型也是有限的支持:
new
来初始化对象System.ValueType
提供的方法public, private, override(有限支持)
等关键字private
今天我们比较全面的介绍了Struct
和Class
的异同, 同时也介绍了他们本质的差别: 值类型和引用类型的基础原理和差异.
相信通过本文, 大家再也不会对两者的感到困惑了.
当然, 今天的内容对于面试和日常使用也是完全够了. 后面有机会我们会再出几篇篇关于Struct
和Class
内存分配的, 还有Class
的继承机制的实现原理等系列文章, 希望感兴趣的小伙伴持续关注.
好了, 今天的内容就是这些, 希望对大家有所帮助.