C++和C#的值类型与引用类型

  1. 1. C# 的引用类型和值类型
  2. 2. C#的可空值类型
  3. 3. C#的装箱与拆箱
  4. 4. 参考文献

最近在看 《CLR Via C#》,看到这本书讨论值类型和引用类型时,我觉得很有必要做点笔记。

我常常在 C++ 项目和 C# 项目之间切换,这两个语言虽然名字很像,但有很大的不同。我曾用着 C++ 的习惯来写 C# 代码,现在发现我当时对 C# 产生了很多误会。

比如两个语言常见的顺序容器。C++ 中的 vector<T> v; 和 C# 中的 List<T> l;。往容器里加入一个对象,C++ 会把这个对象完整地复制一份到 vector 里。我原以为 C# 也是这样,但其实并不是。

所以下面的代码在当时让我很困惑。

1
2
3
4
5
6
7
var m = new MyClass();
m.MemberA = 123;

var list = new List<MyClass>();
list.Add(m); // 将 m 加入 容器
list[0].MemberA = 456; // 修改容器中对象的值
Console.WriteLine(m.MemberA); // print 456,m 竟然被修改了!我以为会输出 123 !

我当时会有这样的困惑,是因为我完全不了解 C# 的类型系统。

C# 的引用类型和值类型

C# 支持两种类型:引用类型和值类型。引用类型的变量存储对其数据(对象)的引用,而值类型的变量直接包含其数据。 对于引用类型,两种变量可引用同一对象;因此,对一个变量执行的操作会影响另一个变量所引用的对象。 对于值类型,每个变量都具有其自己的数据副本,对一个变量执行的操作不会影响另一个变量。[1]

微软文档给出的定义解释了我开头的困惑。MyClass 是引用类型,它的对象 m 只是储存了对数据的引用。而 List<MyClass>.Add 方法创造了另一个引用,所引用的数据与 m 相同。

简单来说,可以用 C++ 中指针的概念来理解引用。

C++ 应该是没有值类型和引用类型的说法的(或者说不存在与 C# 的引用类型和值类型相对应的概念)。但是 C++ 类型的行为默认是 C# 中值类型的行为。

比如函数传递参数时,C++ 和 C# 的值类型都会把参数完整复制一份。C++ 往往用传递 const 引用的方式来省去复制的开销。而 C# 可以用 ref 关键词来传递值类型的引用。

1
2
3
4
// C++ 每次调用 Print 都会将 string 复制一份
void print(string words);
// C++ 传递 const 引用节省一次复制
void print(const string& words);
1
2
3
4
// C# struct MyStringValue 是值类型,每次调用 print 都会将 string 复制一份
void print(MyStringValue words);
// C#
void print(ref MyStringValue words);

传递引用来节省复制是 C++ 引用的常见用法。

写到这里我不知道怎么写下去了,所以就不写了。

// 未完待续。。。

C#的可空值类型

本质是 Nullable 类 + 一些语法糖。c++ 也有类似的东西,也就是 std::optional。

C#的装箱与拆箱

这是 C++ 没有的概念。这概念我也刚认识,下面是笔记,可能有错,建议不看。

C# 里所有类型都基于 object 类型。因此将某个值类型转换为 object 这个基类型是行得通的。

但这其中都发生了什么。要知道 object 可是引用类型,object 储存的是什么呢,是值类型变量的引用吗?

1
2
var p = new Point(3,4); // 假设 Point 是值类型
object obj = p; // 这一步究竟发生了什么,是我们想知道的。

实际上, object 储存的是值类型变量副本的引用。将值类型转化成引用类型的机制称为装箱

装箱做了以下的工作,来实现将值类型转化为引用类型:

  1. 在托管堆中分配足够容纳值类型的内存;
  2. 将值类型各个字段复制到托管堆申请的内存中;
  3. 返回对象的地址,也就是引用。

装箱的逆操作是拆箱。比如下面的代码,将引用类型 object 转化为值类型 int:

1
int v = (int)object;

拆箱做了下面的工作[2]:

  1. 检查类型,确保可以进行转换
  2. 将值从引用的内存中复制到值类型变量

值得一提的是,在《CLR via C#》这本书里对拆箱的定义是获取值类型各个字段的地址,不包括复制的过程。

CLR分两步完成复制。第一步获取已装箱Point对象中的各个Point字段的地址。这个过程称为拆箱(unboxing)。第二步将字段包含的值从堆复制到基于栈的值类型实例中。

两个定义对我来说都是权威的,我不知道该采用哪个。

不过不管是哪个定义,应该要知道将值类型与引用类型的转换是需要复制的,是有较大成本的,应该在编写代码时避免不必要的装箱和拆箱。

参考文献

[1] https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/reference-types

[2]https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/types/boxing-and-unboxing