第 39 节 · 第四部分 进阶特性

不安全代码

指针存的是地址;在 unsafe 里用 & 取地址、* 取值,数组要 fixed 钉住,编译要打开 AllowUnsafeBlocks。

时长 6:50授课计划第 16 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1什么是不安全代码

C# 也能像 C 语言那样用指针直接操作内存,但要写在 unsafe 标记的方法或代码块里,这样的代码叫不安全代码。

C# · 第 6 行
6static unsafe void Main(string[] args)// 在方法前加 unsafe:整个 Main 里都能用指针
C# · 第 8-9 行
8unsafe// 也可以只标一段:unsafe 后面跟一对大括号
9{// unsafe 代码块开始

不安全代码不是非托管代码(不归 .NET 管的代码),教程的说法不准确:它照样由 .NET 运行,垃圾回收(.NET 自动清理不再使用的内存)也照常工作,只是编译器没法替你检查指针用得对不对,所以叫“不安全”。

2指针是什么

内存像一排格子,每个格子都有门牌号,也就是地址。普通变量的格子里放值(比如 20),指针也是变量,但它的格子里放的是另一个格子的地址。真实内存按字节编号,格子和门牌只是比方。

声明写法是 类型* 名字,星号前的类型说明它指向什么:int* 指向整数,double* 指向双精度数,float* 指向浮点数,char* 指向字符;int** 是指向指针的指针,int*[] 是装指针的数组,void* 指向不知道类型的数据。

下面几个短例子是顶级语句写法(不写类和 Main,代码从第一行开始执行),代码都放在一个 unsafe { } 块里。

C# · 第 5-9 行
5Console.WriteLine(sizeof(int*));// sizeof 取类型占几个字节:int* 指针
6Console.WriteLine(sizeof(double*));// double* 指针
7Console.WriteLine(sizeof(char*));// char* 指针
8Console.WriteLine(sizeof(void*));// void* 指针
9Console.WriteLine(sizeof(int**));// int**:指向指针的指针
运行结果
8
8
8
8
8

在 64 位电脑上,指针里只存一个地址,所以五种指针都占 8 个字节(内存按字节编号,一个字节是 8 个二进制位)。

3一句声明多个指针

一句里声明好几个指针时,星号只跟着类型写一次,后面的名字全都是指针。

C# · 第 5-10 行
5int a = 1, b = 2, c = 3;// 三个普通 int 变量:1、2、3
6int* p1, p2, p3;// 星号写在类型后面一次:p1、p2、p3 三个都是 int*
7p1 = &a;// & 是取地址符:p1 存 a 的地址
8p2 = &b;// p2 存 b 的地址
9p3 = &c;// p3 存 c 的地址
10Console.WriteLine(*p1 + *p2 + *p3);// *p 取出指针指向的值:1 + 2 + 3
运行结果
6

照 C 语言的习惯在每个名字前加星号,C# 会报错:

C# · 第 5 行
5int *p1, *p2, *p3;// C 的写法:每个名字前都加星号
编译报错
Program.cs(5,14): error CS1001: 应输入标识符
Program.cs(5,14): error CS1002: 应输入 ;
Program.cs(5,17): error CS1002: 应输入 ;
Program.cs(5,17): error CS1513: 应输入 }

4取地址

Main 前面加了 unsafe,用 & 取出变量 var 的地址交给指针 p,再把 p 强制转换成 int 打印。

C# · 第 8-11 行
8int var = 20;// 普通变量 var,格子里放 20
9int* p = &var;// &var 取出 var 的地址,存进指针 p
10Console.WriteLine("Data is: {0} ", var);// 打印 var 的值:20
11Console.WriteLine("Address is: {0}", (int)p);// 把地址转成 int 打印
查看完整代码(15 行)
C#
1using System;// 引入 System 命名空间
2namespace UnsafeCodeApplication// 定义命名空间 UnsafeCodeApplication
3{// 命名空间开始
4 class Program// 定义 Program 类
5 {// Program 类开始
6 static unsafe void Main(string[] args)// 程序入口 Main,加了 unsafe,里面能用指针
7 {// Main 开始
8 int var = 20;// 普通变量 var,值 20
9 int* p = &var;// 指针 p 存 var 的地址
10 Console.WriteLine("Data is: {0} ", var);// 打印 var 的值:20
11 Console.WriteLine("Address is: {0}", (int)p);// 把地址转成 int 打印
12 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
13 }// Main 结束
14 }// Program 类结束
15}// 命名空间结束
运行结果
Data is: 20 
Address is: 1794463892

再跑一次,地址变成了 1862604916:变量每次放在内存的哪儿并不固定,地址不用记。

本机是 64 位,地址有 8 个字节,转成 int 只留下低 4 个字节;换成 long 才是完整地址:

C# · 第 5-8 行
5int var = 20;// 普通变量 var,值 20
6int* p = &var;// p 存 var 的地址
7Console.WriteLine((long)p);// 转成 long(8 字节):完整地址
8Console.WriteLine((int)p);// 转成 int(4 字节):只剩低 4 字节
运行结果
6129260692
1834293396

5取值和 ->

*p 就是 p 指向的格子里的值;p->ToString() 的箭头意思是先找到 p 指向的值,再调用它的方法。

C# · 第 10-14 行
10int var = 20;// var 的值是 20
11int* p = &var;// p 存 var 的地址
12Console.WriteLine("Data is: {0} " , var);// 直接打印 var:20
13Console.WriteLine("Data is: {0} " , p->ToString());// -> 先取 p 指向的值 20,再调用 ToString 变成字符串
14Console.WriteLine("Address is: {0} " , (int)p);// 打印地址
运行结果
Data is: 20 
Data is: 20 
Address is: 1871206540

取值靠的是箭头,ToString 只负责把数变成字符串,所以第二行也是 20;第三行的地址再跑一次变成了 1837881452。

6指针作方法参数

swap 收两个 int 指针 p 和 q,Main 里 x 指向 var1、y 指向 var2,再把 x 和 y 传进去。

C# · 第 6-11 行
6public unsafe void swap(int* p, int *q)// 两个参数都是 int 指针,收到的是地址
7{// swap 开始
8 int temp = *p;// 先把 p 指向的值(10)存进 temp
9 *p = *q;// 把 q 指向的值(20)放进 p 指向的格子:var1 = 20
10 *q = temp;// 把 temp(10)放进 q 指向的格子:var2 = 10
11}// swap 结束
查看完整代码(28 行)
C#
1using System;// 引入 System 命名空间
2namespace UnsafeCodeApplication// 定义命名空间 UnsafeCodeApplication
3{// 命名空间开始
4 class TestPointer// 定义 TestPointer 类
5 {// TestPointer 类开始
6 public unsafe void swap(int* p, int *q)// swap:收两个 int 指针
7 {// swap 开始
8 int temp = *p;// temp = p 指向的值
9 *p = *q;// p 指向的格子 = q 指向的值
10 *q = temp;// q 指向的格子 = temp
11 }// swap 结束
12
13 public unsafe static void Main()// 程序入口 Main,加了 unsafe
14 {// Main 开始
15 TestPointer p = new TestPointer();// 创建对象,用来调用 swap
16 int var1 = 10;// var1 = 10
17 int var2 = 20;// var2 = 20
18 int* x = &var1;// x 指向 var1
19 int* y = &var2;// y 指向 var2
20
21 Console.WriteLine("Before Swap: var1:{0}, var2: {1}", var1, var2);// 交换前:10 和 20
22 p.swap(x, y);// 把两个地址传给 swap
23
24 Console.WriteLine("After Swap: var1:{0}, var2: {1}", var1, var2);// 交换后:20 和 10
25 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
26 }// Main 结束
27 }// TestPointer 类结束
28}// 命名空间结束
运行结果
Before Swap: var1:10, var2: 20
After Swap: var1:20, var2: 10

方法拿到的是地址,改的是原来那两个格子,所以 Main 里的变量也跟着变了。实测输出里 var1: 后面没有空格。

7用指针访问数组

int* 是指针,int[] 是数组,两者类型不同。数组放在垃圾回收管理的内存里,垃圾回收整理内存时可能把它整个挪走,指针就指错了地方;所以要用 fixed 在这段代码运行期间把数组钉住,不让它挪动。fixed 是 C# 自己的关键字,C 和 C++ 里没有。

不写 fixed,直接取数组元素的地址,编译器报错:

C# · 第 5-6 行
5int[] list = { 10, 100, 200 };// 数组 list:10、100、200
6int* ptr = &list[0];// 不用 fixed,直接取数组第 0 个元素的地址
编译报错
Program.cs(6,16): error CS0212: 只能获取 fixed 语句初始值设定项内的未固定表达式的地址
C# · 第 8-15 行
8int[] list = {10, 100, 200};// 数组 list:10、100、200
9fixed(int *ptr = list)// fixed 钉住数组 list,ptr 指向它的开头
10
11/* 显示指针中数组地址 */// 一行注释(/* */ 写法)
12for ( int i = 0; i < 3; i++)// i 从 0 到 2,逐个访问
13{// for 循环开始
14 Console.WriteLine("Address of list[{0}]={1}",i,(int)(ptr + i));// ptr + i 是第 i 个元素的地址
15 Console.WriteLine("Value of list[{0}]={1}", i, *(ptr + i));// 前面加 * 取出第 i 个元素的值
查看完整代码(20 行)
C#
1using System;// 引入 System 命名空间
2namespace UnsafeCodeApplication// 定义命名空间 UnsafeCodeApplication
3{// 命名空间开始
4 class TestPointer// 定义 TestPointer 类
5 {// TestPointer 类开始
6 public unsafe static void Main()// 程序入口 Main,加了 unsafe
7 {// Main 开始
8 int[] list = {10, 100, 200};// 数组 list:10、100、200
9 fixed(int *ptr = list)// 钉住 list,ptr 指向开头;钉住范围是下面的 for 循环
10
11 /* 显示指针中数组地址 */// 一行注释(/* */ 写法)
12 for ( int i = 0; i < 3; i++)// i 从 0 到 2
13 {// for 循环开始
14 Console.WriteLine("Address of list[{0}]={1}",i,(int)(ptr + i));// 打印第 i 个元素的地址
15 Console.WriteLine("Value of list[{0}]={1}", i, *(ptr + i));// 打印第 i 个元素的值
16 }// for 循环结束
17 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
18 }// Main 结束
19 }// TestPointer 类结束
20}// 命名空间结束
运行结果
Address of list[0]=54328
Value of list[0]=10
Address of list[1]=54332
Value of list[1]=100
Address of list[2]=54336
Value of list[2]=200

相邻两个元素的地址差 4:一个 int 占 4 个字节,指针加 1,地址往后走 4 个字节。再跑一次地址全变了(534844472、534844476、534844480),但还是每次差 4。

8编译不安全代码

用命令行编译器时写 csc /unsafe;现在的 .NET 项目要在项目文件(.csproj)里加上一行 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>。没写这一行,前面取地址的例子编译到 Main 这行就报错:

C# · 第 6 行
6static unsafe void Main(string[] args)// Main 标了 unsafe,但项目没允许不安全代码
编译报错
Program.cs(6,28): error CS0227: 不安全代码只会在使用 /unsafe 编译的情况下出现

在 Visual Studio 里:打开项目的属性,在“生成”页勾选“允许不安全代码”。这是微软文档写的步骤,新版 VS 的属性页排版可能不一样。

9stackalloc

还有一个 stackalloc:它直接在栈(方法运行时临时存放局部变量的内存)上分出一块内存。栈上的内存不归垃圾回收管,所以不用 fixed,方法一返回这块内存就自动丢掉。

C# · 第 5-10 行
5int* nums = stackalloc int[3];// 在栈上分出 3 个 int 的空间,nums 指向开头
6for (int i = 0; i < 3; i++)// i 从 0 到 2
7{// for 循环开始
8 nums[i] = (i + 1) * 10;// 指针也能用 [i]:依次放进 10、20、30
9}// for 循环结束
10Console.WriteLine(nums[0] + nums[1] + nums[2]);// 10 + 20 + 30
运行结果
60

10小结

  • unsafe:标记能用指针的方法或代码块。
  • & 取地址,* 按地址取值,-> 取值后调用它的方法。
  • fixed:把数组钉住,不让垃圾回收挪动;stackalloc:在栈上分配,不用钉。
  • 编译时要打开 AllowUnsafeBlocks;地址每次运行都不一样。

下一节:指针能直接改内存;要是一个程序里好几件事同时在跑呢?

检查题

5 道,选完马上告诉你对不对。

  1. 1int* p1, p2, p3; 里 p2 的类型?

  2. 2同一程序运行两次,打印 (int)p 的结果?

  3. 3int 数组用 fixed 取指针 ptr,ptr + 1 的地址比 ptr 大?

  4. 4没在项目里打开 AllowUnsafeBlocks 就编译带 unsafe 的代码?

  5. 5为什么取数组元素地址要写 fixed?