今天我们来探索一下老生常谈的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的继承机制的实现原理等系列文章, 希望感兴趣的小伙伴持续关注.
好了, 今天的内容就是这些, 希望对大家有所帮助.