环境:
参考:
《C#中定义装箱和拆箱详解》
《c# struct 灵魂拷问》
《[译]C# 7系列,Part 6: Read-only structs 只读结构》
《[译]C# 7系列,Part 9: ref structs ref结构》
《.NET高性能编程 - C#如何安全、高效地玩转任何种类的内存之Span的本质(一)。》
《.NET高性能编程 - C#如何安全、高效地玩转任何种类的内存之Span的秉性特点(二)。》
说明:最近看到关于Span的介绍,其中涉及到《值类型、引用类型、装箱和拆箱、结构体、readonly、ref》的知识,这里做一下总结。
c#的数据类型分两类:
值类型: Sbyte、Byte、Short、Ushort、Int、Uint、Long、Ulong、Char、Float、Double、Bool、Decimal、枚举(enum)、结构(struct);
它们全部隐式继承自:abstract class System.ValueType,也就是说代码定义时你只需要定义为struct,编译后它就会自动继承System.ValueType,而你在写代码的时候无法去给struct指定继承任何东西。
引用类型:类、数组、接口、委托、字符串等;
程序中的主要内存类型:
空间较小,方法的调用、代码执行、本地变量、方法参数都存储在这里。
空间较大,引用类型变量的数据都存储在这里,同时栈内存中会有这里地址的引用。
示意图如下:
程序方法中对于值类型和引用类型的赋值情况如下:
当我们代码中使用object类型变量指向一个int类型数据时,.net就会自动将这个int数据拷贝到堆上,然后将分配的地址告诉这个object类型变量,这叫装箱;
当我们将上面定的的object类型变量又强转成int类型时,.net就会自动将这个int数据从堆上拷贝到栈里,这叫拆箱;
考虑下面简单的代码:
public static void Main()
{
// i是值类型,存储在栈中
int i = 5;
// obj是引用类型变量,将i复制到堆中,并把地址给obj
object obj = i;
// obj指向的堆中的数据复制到栈中转成int类型给变量i
i = (int)obj;
}
我们知道,代码中比较两个对象是否相等是基本操作,对于引用类型默认调用Object.Equals方法,比较的是内存地址,那么至于值类型是怎么比较的呢?
一般我们定义方法如下:
public void Show(Object p){}
public void Add(int i,int j){}
上面两个方法调用的时候都是将变量拷贝一下再传递进去的,称为传值。
对于引用类型变量,拷贝的变量在堆内存中的地址,虽然不是一个变量了,但它们指向的地址是相同的;
对于值类型变量,由于它们的数据就在栈中,所以拷贝的是数据本身,所以我们在上面的Add方法中修改i和j的值,无法反馈到外层调用者。为了解决这个问题,c#中提供了ref关键字,当方法参数前加上它后,.net在调用时就直接将值类型的地址传了进来,而不是传一个拷贝的数据,此时我们在Add方法中修改i和j的值,外层调用者也能看到,定义如下:
public void Add(ref int i,ref int j){};
对于引用类型变量参数,我们也可以加ref关键字,原理一致,效果就是我们在方法内对p赋值,那么外层调用者也能看到这个变量指向的堆地址改变了。
另外,ref关键字不仅能用在方法参数前,在方法中也可以使用,如下:
public static void Show()
{
var person1 = new Person() { Age = 18 };
// 将person1指向的内存地址赋值给person2
var person2 = person1;
// 改变person2指向的内存地址,此时person1指向的内存地址并没有变
person2 = new Person() { Age = 19 };
Debug.Assert(person1.Age == 18);
//将person1变量的地址赋值给person3(注意:不是将person1指向的地址赋值给person3),此时可认为person3就是person1
ref Person person3 = ref person1;
person3 = new Person() { Age = 20 };
Debug.Assert(person1.Age == 20);
}
int等数据类型除了是c#中的基础类型(值类型)之外,它们还都是结构体。
结构体和类看起来很像,但是它们有很大不同:
关于struct的问题:
其实这是一个约定,c#中结构体并没有继承的功能,但总有一些方法要复用,所以编译器不让我们写继承,而是它自己偷偷的继承了ValueType。
struct可以实现接口,因为接口本身是一系列方法的签名。
不过,因为类和接口都是引用类型,而struct是值类型,所以当使用接口变量指向结构体时会将结构体装箱到堆中。当又使用结构体变量指向上面装箱的接口时就会发生拆箱的动作,这一装一拆可能会导致你对结构体更改的内容失效,如下代码:
public static void Main()
{
var cat = new Cat { Id = 1 };
//栈中的数据
Console.WriteLine($"cat.Id={cat.Id}");//输出: cat.Id=1
var animal = cat as IAnimal;
animal.Update(2);
//装箱后到了堆里,并在堆里改了值,打印出堆里的值
Console.WriteLine($"animal.Show()={animal.Show()}");//输出: animal.Show()=2
var cat2 = (Cat)animal;
//拆箱后把数据从堆里复制到了栈,新的结构体
Console.WriteLine($"cat2.Id={cat2.Id}");//输出: cat2.Id=2
//栈中原来的数据并不受影响
Console.WriteLine($"cat.Id={cat.Id}");//输出: cat.Id=1
cat.Id = 3;
//栈中的数据直接修改为
Console.WriteLine($"cat.Id={cat.Id}");//输出: cat.Id=3
//栈中的数据直接修改并不影响堆里的数据以及新的结构体数据
Console.WriteLine($"animal.Show()={animal.Show()},cat2.Id={cat2.Id}");//输出: animal.Show()=2,cat2.Id=2
}
没错,和类不同,在struct方法中,你可以给this赋值,负值后这个结构体的数据奖杯覆盖,如下:
class TestDemo
{
public static void Main()
{
var cat = new Cat { Id = 1 };
var cat2 = new Cat { Id = 2, Name = "小明" };
//相当于将cat2中的数据拷贝到cat中
//但cat和cat2仍然是两块内存
cat.Update(cat2);
Console.WriteLine($"cat.Id={cat.Id},cat.Name={cat.Name}");//输出: cat.Id=2,cat.Name=小明
}
}
public struct Cat
{
public int Id { get; set; }
public string Name { get; set; }
public void Update(Cat cat)
{
this = cat;
}
}
即只读的struct,如果struct不是只读的话,那么它在装箱和拆箱的时候可能会影响到你的代码逻辑。
我们习惯对类属性的修改操作是一处修改,处处修改,因为类的数据存储在堆中,无论我们用接口或者是数据类型本身去引用它都不是拆箱装箱,不会产生数据副本,所以数据只有一份,自然是修改一处,处处修改。
但通过上面的示例,我们看到系统在自动拆箱装箱的时候会产生数据不一致的情况,所以出现了readonly,当我们把结构体声明为readonly的时候,就表示我们仅希望在创建结构体时对成员赋值,一旦创建完毕就不要再改动里面的值了,如下:
class TestDemo
{
public static void Main()
{
var cat = new Cat(1, "小明");
//因为cat是readonly的,所以系统仅允许我们构建时赋值,在装箱拆箱后均不允许修改结构体内容,所以能避免数据不一致的问题
var animal = cat as IAnimal;
var cat2 = (Cat)animal;
// 因为除了在构造函数中不允许修改结构体数据,所以即使发生了装箱和拆箱也不影响最终数据的一致性
// 但我们要知道,cat和cat2是栈内存中两个不同的地址,而animal引用的数据在堆中
}
}
public readonly struct Cat : IAnimal
{
public readonly int Id { get; }
public readonly string Name { get; }
public Cat(int id, string name)
{
this.Id = id;
this.Name = name;
}
public Cat(Cat cat)
{
// 构造函数中可以改数据,自然也可以对this赋值
this = cat;
}
public void Update(int id)
{
//因为readonly已经要求属性必须是只读的所以下面肯定报错
//this.Id = id;
}
public void UpdateThis(Cat cat)
{
//因为结构体是只读的,所以不允许对this赋值
//this = cat;
}
public void UpdateId(int id)
{
//readonly的,报错
//this.Id = id;
}
}
public interface IAnimal
{
void Update(int id);
}
可以看出加了readonly的struct虽然限制了其功能,但解决了数据一致性的问题!
在c#中我们能看到Span
结构体的定义如下:
那么在struct加上readonly ref是什么作用呢?
答: 限制结构体必须存储在栈中!!!
既然限制在栈中,那么就不会发生装箱和拆箱,那么还能实现接口嘛?不能了!!下面代码报错:
限制结构体只能存储在栈中的后果不止不能实现接口,还有:
那么,ref的结构体应该怎么使用呢?
答: 应该仅考虑将它同在局部变量或非异步的参数上。