文章目录
- 1. C#和.NET框架
- .NET框架的组成
- .NET框架的特点
- CLR
- CLI
- CLI的重要组成部分
- 各种缩写
- 2. C#编程概括
- 标识符
- 命名规则:
- 多重标记和值
- 格式化数字字符串
- 对齐说明符
- 格式字段
- 标准数字格式说明符
- 标准数字格式说明符 表
- 3. 类型、存储和变量
- 数据成员和函数成员
- 预定义类型
- 预定义简单类型
- 预定义非简单类型
- 用户定义类型
- 堆和栈
- 栈
- 堆
- 值类型和引用类型
- 存储引用类型对象的成员
- C#类型的分类
- 变量
- 4. 类
- 类成员
- 字段
- 方法
- 创建变量和类的实例
- 访问修饰符
- 私有访问和公有访问
- 5. 方法
- 局部变量
- var 关键字
- 局部常量
- 局部函数
- 参数
- 引用参数
- 引用类型作为值参数和引用参数
- 第一种情况 将引用类型对象作为值参数传递
- 输出参数
- 参数数组
- 方法调用
- 参数类型总结
- ref 局部变量和 ref 返回
- 方法重载
- 命名参数
- 可选参数
- 栈帧
- 递归
- 6.深入理解类
- 成员修饰符的顺序
- 静态字段
- 成员常量
- 属性
- 访问器
- 属性和字段的命名约定
- 属性与公有字段
- 自动实现属性
- 静态属性
- 实例构造函数
- 默认构造函数
- 静态构造函数
- readonly 修饰符
- this 关键字
- 索引器
- 索引器和属性
- 声明索引器
- 索引器重载
- 访问器的访问修饰符
- 访问器的访问修饰符有几个限制:
- 分部类和分部类型
- 分部方法
- 7. 类和继承
- 类继承
- 屏蔽基类的成员
- 基类访问
- 使用基类的引用
- 虚方法和覆写方法
- 类访问修饰符
- 程序集间的继承
- 成员访问修饰符
- 成员可访问性总结
- 抽象成员
- 抽象类
- 密封类
- 静态类
- 扩展方法
- 8. 表达式和运算符
- 9. 语句
- using 语句
- 包装资源的使用
- 多个资源和嵌套
- using 语句的另一种形式
- 10. 结构
- 类和结构在内存的安排
- 对结构和类赋值的区别
- 构造函数和析构函数
- 实例构造函数
- 静态构造函数
- 11. 枚举
- 设置底层类型和显式值
- 位标志
- 12. 数组
- Clone 方法
- 13. 委托
- 委托使用步骤
- 为委托添加方法
- 为委托移除方法
- 调用带返回值的委托
- 调用带引用参数的委托
- 匿名方法
- 语法:
- 使用匿名方法
- 返回值
- 参数
- params 参数
- 变量和参数的作用域
- 外部变量
- 捕获变量的生命周期的扩展
- Lambda 表达式
- 事件
- 发布者和订阅者
- 源代码组件概览
- 声明事件
- 订阅事件
- 触发事件
- 15. 接口
- 声明接口
- 接口是引用类型
- as 运算符
- 多接口
- 16. 转换
- is 运算符
- 17. 泛型
- 泛型类
- 声明泛型类
- 创建构造类型
- 创建变量和实例
- 类型参数的约束
- Where 子句
- 约束类型和次序
- 约束类型
- 次序
- 泛型方法
- 声明泛型方法
- 调用泛型方法
- 类型推断
- 扩展方法和泛型类
- 泛型结构
- 泛型委托
- 泛型接口
- 泛型接口的实现必须唯一
- 逆变和协变
- 协变
- 逆变
- 18. 枚举器和迭代器
- 枚举器和可枚举类型
- 使用 foreach 语句
- IEnumerator 接口
- IEnumerable 接口
- 泛型枚举接口
- 迭代器
- 迭代器块
- 使用迭代器来创建枚举器
- 使用迭代器创建可枚举类型
- 迭代器的实质
- 19. LINQ
- 匿名类型
- 方法语法和查询语法
- 查询变量
- 查询表达式的结构
- from 子句
- join 子句
- 标准查询运算符
- LINQ to XML
- XML 基础
- XML 类
- 20. 异步编程
- 什么是异步
- async/await
- 异步方法
- await 表达式
- 取消一个异步操作
- 异常处理和 await 表达式
- 在调用方法中同步地等待任务
- 在异步方法中异步地等待任务
- Task.Delay 方法
- Task.Yield
- 使用异步 Lambda 表达式
- BackgroundWorker 类
- 并行循环
- 其他异步编程
- 异步方法调用的三种标准模式
- 21. 命名空间和程序集
- 22. 异常
- 23. 预处理指令
- 24. 反射和特性
- 元数据和反射
- Type 类
- System.Type 类的部分成员
- 获取 Type 对象
- 什么是特性
- 应用特性
- 预定义的保留特性
- Obsolete 特性
- Conditional 特性
1. C#和.NET框架
.NET框架的组成
.NET框架由三部分组成,严格地说CLR(Common Language Runtime,公共语言运行库)和FCL(框架类库)两部分组成,不包括工具。(FCL是BCL的超集,还包括Windows Forms、ASP.NET、LINQ以及更多命名空间)
.NET框架的特点
- 面向对象的开发环境
- 自动垃圾收集:CLR有一项服务称为GC(Garbage Collector,垃圾收集器),它能为你自动管理内存。
- 互操作性
- 不需要COM
- 简化的部署
- 类型安全性
- 基类库
CLR
.NET框架的核心组件是CLR,它在操作系统的顶层,负责管理程序的执行。
CLR还提供下列服务:
- 自动垃圾收集;
- 安全和认证;
- 通过访问BCL得到广泛的编程功能,包括如Web服务和数据服务之类的功能。
CLI
CLI(Common Language Infrastructure,公共语言基础结构)就是一组标准,它把所有.NET框架的组件连结成一个内聚的、一致的系统。它展示了系统的概念和架构,并详细说明了所有软件都必须坚持的规则和约定。
CLI的重要组成部分
-
公共类型系统
CTS(Common Type System,公共类型系统)定义了那些在托管代码中一定会使用的类型的特征。CTS的一些重要方面如下。- CTS定义了一组丰富的内置类型,以及每种类型固有的、独有的特性。
- .NET兼容编程语言提供的类型通常映射到CTS中已定义的内置类型集的某一个特殊子集。
- CTS最重要的特征之一是所有类型都继承自公共的基类——
object
。 - 使用CTS可以确保系统类型和用户定义类型能够被任何.NET兼容的语言所使用。
-
公共语言规范
CLS(Common Language Specification,公共语言规范)详细说明了一个.NET兼容编程语言的规则、属性和行为,其主题包括数据类型、类结构和参数传递。
各种缩写
2. C#编程概括
标识符
标识符是一种字符串,用来命名变量、方法、参数和许多后面将要阐述的其他程序结构。
命名规则:
- 字母和下划线(a-z、A-Z 和 _ )可以用在任何位置。
- 数字不能放在首位,但可以放在其他的任何位置。
- @字符只能放在标识符的首位。(虽然允许使用,但不推荐)
多重标记和值
在 C#中,可以使用任意数量的替代标记和任意数量的值。
- 值可以以任何顺序使用。
- 只可以在格式字符串中替换任意次。
例如:
Console.WriteLine("Three integers are {1},{0} and {1}.", 3, 6);
控制台输出结果:
Three integers are 6,3 and 6.
格式化数字字符串
例子:
Console.WriteLine("The value: {0}." , 500); // 输出数字
Console.WriteLine("The value: {0:C}.", 500); // 格式为货币
↑
格式化为货币
这段代码产生了如下的输出:
The value: 500.
The value: ¥500.00.
两条语句的不同之处在于,格式项以格式说明符形式包括了额外的信息。大括号内的格式说明符的语法由3个字段组成:索引号、对齐说明符和格式字段(format field)
对齐说明符
-
对齐说明符表示了字段中字符的最小宽度。对齐说明符有如下特性。
- 对齐说明符是可选的,并且使用逗号来和索引号分离。
- 它由一个正整数或负整数组成。
- 整数表示了字段使用字符的最少数量。
- 符号表示了右对齐或左对齐。正数表示右对齐,负数表示左对齐。
索引——使用列表中的第0项 ↓ Console.WriteLine("{0, 10}", 500); ↑ 对齐说明符——在10个字符的字段中右对齐
例如,如下格式化
int
型变量myInt
的值的代码显示了两个格式项。在第一个示例中,myInt
的值以在10个字符的字符串中右对齐的形式进行显示;第二个示例中则是左对齐。格式项放在两个竖杠中间,这样在输出中就能看到它们的左右边界。int myInt = 500; Console.WriteLine("|{0, 10}|", myInt); // 右对齐 Console.WriteLine("|{0,-10}|", myInt); // 左对齐
这段代码产生了如下的输出,在两个竖杠的中间有10个字符:
| 500|
|500 |值的实际表示可能会比对齐说明符指定的字符数多一些或少一些:
- 如果要表示的字符数比对齐说明符中指定的字符数少,那么其余字符会使用空格填充;
- 如果要表示的字符数多于指定的字符数,对齐说明符会被忽略,并且使用所需的字符进行表示。
格式字段
格式字段指定了数字应该以哪种形式表示。例如,应该被当做货币、十进制数字、十六进制数字还是定点符号来表示?
格式字段有三部分,如图2-4所示。
- 冒号后必须紧跟着格式说明符,中间不能有空格。
- 格式说明符是一个字母字符,是9个内置字符格式之一。字符可以是大写或小写形式。大小写对于某些说明符来说比较重要,而对于另外一些说明符来说则不重要。
- 精度说明符是可选的,由1~2位数字组成。它的实际意义取决于格式说明符。
图2-4 标准的格式字段字符串
如下代码是格式字符串组件语法的一个示例:
索引——使用列表中的第0项
↓
Console.WriteLine("{0:F4}", 12.345678);
↑
格式组件——4位小数的定点数
如下代码给出了不同格式字符串的一些示例:
double myDouble = 12.345678;
Console.WriteLine("{0,-10:G} -- General", myDouble);
Console.WriteLine("{0,-10} -- Default, same as General", myDouble);
Console.WriteLine("{0,-10:F4} -- Fixed Point, 4 dec places", myDouble);
Console.WriteLine("{0,-10:C} -- Currency", myDouble);
Console.WriteLine("{0,-10:E3} -- Sci. Notation, 3 dec places", myDouble);
Console.WriteLine("{0,-10:x} -- Hexadecimal integer", 1194719 );
这段代码产生了如下的输出:
12.345678 -- General 12.345678 -- Default, same as General 12.3457 -- Fixed Point, 4 dec places $12.35 -- Currency 1.235E+001 -- Sci. Notation, 3 dec places 123adf -- Hexadecimal integer
标准数字格式说明符
表2-4总结了9种标准数字格式说明符。第一列在说明符名后列出了说明符字符。如果说明符字符根据它们的大小写会有不同的输出,就会标注为区分大小写。
标准数字格式说明符 表
名字和字符 | 意义 |
---|---|
货币 C 、c | 使用货币符号把值格式化为货币,货币符号取决于程序所在PC的区域设置 精度说明符:小数位数 示例: Console.WriteLine("{0:C}",12.5); 输出: $12.50 |
十进制数 D 、d | 十进制数字字符串,需要的情况下有负数符号。只能和整数类型配合使用 精度说明符:输出字符串中的最少位数。如果实际数字的位数更少,则在左边以0填充 示例: Console.WriteLine("{0:D4}",12); 输出: 0012 |
定点 F 、f | 带有小数点的十进制数字字符串。如果需要也可以有负数符号 精度说明符:小数的位数 示例: Console.WriteLine("{0:F4}",12.3456789); 输出: 12.3457 |
常规 G 、g | 在没有指定说明符的情况下,会根据值转换为定点或科学记数法表示的紧凑形式 精度说明符:根据值 示例: Console.WriteLine("{0,G4}",12.345678); 输出: 12.35 |
十六进制数 X 、x 区分大小写 | 十六进制数字的字符串。十六进制数字A~F会匹配说明符的大小写形式 精度说明符:输出字符串中的最少位数。如果实际数的位数更少,则在左边以0填充 示例: Console.WriteLine("{0:x}",180026); 输出: 2bf3a |
数字 N 、n | 和定点表示法相似,但是在每三个数字的一组中间有逗号或空格分隔符。从小数点开始往左数。使用逗号还是空格分隔符取决于程序所在PC的区域设置 精度说明符:小数的位数 示例: Console.WriteLine("{0:N2}",12345678.54321); 输出: 12,345,678.54 |
百分比 P 、p | 表示百分比的字符串。数字会乘以100 精度说明符:小数的位数 示例: Console.WriteLine("{0:P2}",0.1221897); 输出: 12.22% |
往返过程 R 、r | 保证输出字符串后如果使用Parse方法将字符串转化成数字,那么该值和原始值一样。Parse 方法将在第25章描述 精度说明符:忽略 示例: Console.WriteLine("{0:R}",1234.21897); 输出: 1234.21897 |
科学记数法 E 、e 区分大小写 | 具有尾数和指数的科学记数法。指数前面加字母E。E的大小写和说明符一致 精度说明符:小数的位数 示例: Console.WriteLine("{0:e4}",12.3456789); 输出: 1.2346e+001 |
3. 类型、存储和变量
数据成员和函数成员
像short
、int
和long
等这样的类型称为简单类型。这种类型只能存储一个数据项。
其他的类型可以存储多个数据项。比如数组(array)类型就可以存储多个同类型的数据项。这些数据项称为数组元素。可以通过数字来引用这些元素,这些数字称为索引。
预定义类型
C#提供了16种预定义类型,其中包括13种简单类型和3种非简单类型。
所有预定义类型的名称都由全小写的字母组成。预定义的简单类型包括以下3种。
- 11种数值类型。
- 不同长度的有符号和无符号整数类型。
- 浮点数类型
float
和double
。 - 一种称为
decimal
的高精度小数类型。与float
和double
不同,decimal
类型可以准确地表示分数。decimal
类型常用于货币的计算。
- 一种Unicode字符类型
char
。 - 一种布尔类型
bool
。bool
类型表示布尔值并且必须为true
或false
。
说明 与C和C++不同,在C#中的数值类型不具有布尔意义。
3种非简单类型如下。
string
,它是一个Unicode字符数组。object
,它是所有其他类型的基类。dynamic
,使用动态语言编写的程序集时使用。
预定义简单类型
名称 | 含义 | 范围 | .NET框架类型 | 默认值 |
---|---|---|---|---|
sbyte | 8位有符号整数 | -128~127 | System.SByte | 0 |
byte | 8位无符号整数 | 0~255 | System.Byte | 0 |
short | 16位有符号整数 | -32 768~32 767 | System.Int16 | 0 |
ushort | 16位无符号整数 | 0~65 535 | System.UInt16 | 0 |
int | 32位有符号整数 | -2 147 483 648~2 147 483 647 | System.Int32 | 0 |
uint | 32位无符号整数 | 0~4 294 967 295 | System.UInt32 | 0 |
long | 64位有符号整数 | -9 223 372 036 854 775 808 ~9 223 372 036 854 775 807 | System.Int64 | 0 |
ulong | 64位无符号整数 | 0~18 446 744 073 709 551 615 | System.UInt64 | 0 |
float | 单精度浮点数 | 1.5×10-45~3.4×1038 | System.Single | 0.0f |
double | 双精度浮点数 | 5×10-324~1.7×10308 | System.Double | 0.0d |
bool | 布尔型 | true false | System.Boolean | false |
char | Unicode 字符串 | U+0000~U+ffff | System.Char | \x0000 |
decimal | 小数类型的有效数字精度为28位 | ±1.0×1028~±7.9×1028 | System.Decimal | 0m |
非简单预定义类型稍微复杂一些。表3-2所示为非简单预定义类型。 |
预定义非简单类型
名称 | 含义 | .NET框架类型 |
---|---|---|
object | 所有其他类型的基类,包括简单类型 | System.Object |
string | 0个或多个Unicode字符所组成的序列 | System.String |
dynamic | 在使用动态语言编写的程序集时使用 | 无相应的.NET类型 |
用户定义类型
除了C#提供的16种预定义类型,还可以创建自己的用户定义类型。有6种类型可以由用户自己创建,它们是:
- 类类型(
class
); - 结构类型(
struct
); - 数组类型(
array
); - 枚举类型(
enum
); - 委托类型(
delegate
); - 接口类型(
interface
)。
类型通过类型声明创建,类型声明包含以下信息:
- 要创建的类型的种类;
- 新类型的名称;
- 对类型中每个成员的声明(名称和规格)。
array
和delegate
类型除外,它们不含有命名成员。
一旦声明了类型,就可以创建和使用这种类型的对象,就像它们是预定义类型一样。
堆和栈
运行中的程序使用两个内存区域来存储数据:栈和堆。
栈
栈是一个内存数组,是一个LIFO(Last-In First-Out,后进先出)的数据结构。栈存储几种类型的数据:
- 某些类型变量的值;
- 程序当前的执行环境;
- 传递给方法的参数。
系统管理所有的栈操作。作为程序员,你不需要显式地对它做任何事情。但了解栈的基本功能可以更好地了解程序在运行时正在做什么,并能更好地了解C#文档和著作。
栈的特征
栈有如下几个普遍特征。
- 数据只能从栈的顶端插入和删除。
- 把数据放到栈顶称为入栈(push)。
- 从栈顶删除数据称为出栈(pop)。
堆
堆是一块内存区域,在堆里可以分配大块的内存用于存储某类型的数据对象。与栈不同,堆里的内存能够以任意顺序存入和移除。
展示了一个在堆里放了4项数据的程序
虽然程序可以在堆里保存数据,但并不能显式地删除它们。CLR的自动GC(Garbage Collector,垃圾收集器)在判断出程序的代码将不会再访问某数据项时,自动清除无主的堆对象。我们因此可以不再操心这项使用其他编程语言时非常容易出错的工作了。
阐明垃圾收集过程
值类型和引用类型
数据项的类型定义了存储数据需要的内存大小及组成该类型的数据成员。类型还决定了对象在内存中的存储位置——栈或堆。
类型被分为两种:值类型和引用类型,这两种类型的对象在内存中的存储方式不同。
- 值类型只需要一段单独的内存,用于存储实际的数据。
- 引用类型需要两段内存。
- 第一段存储实际的数据,它总是位于堆中。
- 第二段是一个引用,指向数据在堆中的存放位置。
每种类型的单个数据项是如何存储的?对于值类型,数据存放在栈里。对于引用类型,实际数据存放在堆里而引用存放在栈里。
图3-9 非成员数据的存储
存储引用类型对象的成员
图3-9阐明了当数据不是另一个对象的成员时如何存储。如果它是另一个对象的成员,那么它的存储会有些不同。
- 引用类型对象的数据部分始终存放在堆里,如图3-9所示。
- 值类型对象,或引用类型数据的引用部分可以存放在堆里,也可以存放在栈里,这依赖于实际环境。
例如,假设有一个引用类型的实例,名称为MyType
,它有两个成员:一个值类型成员和一个引用类型成员。它将如何存储呢?是否是值类型的成员存储在栈里,而引用类型的成员如图3-9所示的那样在栈和堆之间分成两半呢?答案是否定的。
请记住,对于一个引用类型,其实例的数据部分始终存放在堆里。既然两个成员都是对象数据的一部分,那么它们都会被存放在堆里,无论它们是值类型还是引用类型。图3-10阐明了MyType
的情形。
- 尽管成员A是值类型,但它也是
MyType
实例数据的一部分,因此和对象的数据一起被存放在堆里。 - 成员B是引用类型,所以它的数据部分会始终存放在堆里,正如图中“数据”框所示。不同的是,它的引用部分也被存放在堆里,封装在
MyType
对象的数据部分中。
图3-10 引用类型成员数据的存储
说明 对于引用类型的任何对象,它所有的数据成员都存放在堆里,无论它们是值类型还是引用类型。
C#类型的分类
表3-3列出了C#中可以使用的所有类型以及它们的类别:值类型或引用类型。每种引用类型都将在后面的内容中阐述。
表3-3 C#中的值类型和引用类型
值类型 | 引用类型 | |
---|---|---|
预定义类型 | sbyte byte float short ushort double int uint char long ulong decimal bool | object string dynamic |
用户定义类型 | struct enum | class interface delegate array |
变量
一种多用途的编程语言必须允许程序存取数据,而这正是通过变量实现的。
- 变量是一个名称,表示程序执行时存储在内存中的数据。
- C#提供了4种变量,每一种都将详细讨论。表3-4列出了变量的种类。
表3-4 4种变量
名称 | 描述 |
---|---|
局部变量 | 在方法的作用域保存临时数据,不是类型的成员 |
字段 | 保存和类型或类型实例相关的数据,是类型的成员 |
参数 | 用于从一个方法到另一个方法传递数据的临时变量,不是类型的成员 |
数组元素 | (通常是)同类数据项构成的有序集合的一个成员,可以为本地变量,也可以为类型的成员 |
4. 类
一个 C#类可以有任意数目的数据成员和函数成员。成员可以是9种成员类型的任意组合。
表4-1 类成员的类型
数据成员存储数据 | 函数成员执行代码 |
---|---|
字段、常量 | 方法、属性、构造函数、析构函数、运算符、索引器、事件 |
类成员
字段和方法是最重要的类成员类型。字段是数据成员,方法是函数成员。
字段
字段是隶属于类的变量。
- 它可以是任何类型,无论是预定义类型还是用户定义类型。
- 和所有变量一样,字段用来保存数据,并具有如下特征:
- 可以被写入
- 可以被读取
方法
方法是具有名称的可执行代码块,可以从程序的很多不同地方执行,甚至从其他程序中执行。
声明方法的最简语法包括以下组成部分:
- 返回类型
- 名称
- 参数列表
- 方法体
创建变量和类的实例
类的声明只是用于创建类的实例的蓝图。一旦类被声明,就可以创建类的实例。
- 类是引用类型,它们要为数据引用和实际数据都申请内存。
- 数据的引用保存在一个类类型的变量中。所以,要创建类的实例,需要从声明一个类类型的变量开始。如果变量没有被初始化,他的值是未定义的。
访问修饰符
访问修饰符是成员声明的可选部分,指明程序的其他部分如何访问成员。
- 私有的:private
- 公有的:public
- 受保护的:protected
- 内部的:internal
- 受保护内部的:protected internal
私有访问和公有访问
私有成员只能从声明他的类的内部访问,其他的类看不见或无法访问:
- 私有访问是默认的访问级别,如果一个成员在声明时不带访问修饰符,那它就是私有成员。
- 还可以显式的声明私有成员。
例如,下面的两个声明都指定了 private int 成员:
int MyInt1; //隐式声明为私有
private int MyInt2; //显式声明为私有
实例的公有成员可以被程序中的其他对象访问。必须使用 public 访问修饰符指定公有访问。
5. 方法
方法主要有两个部分:方法头和方法体。
- 方法头指定方法的特征,包括:
- 方法是否返回数据,如果返回,返回类型。
- 方法的名称。
- 哪种类型的数据可以传递给方法或从方法返回,以及应如何处理这些数据。
- 方法体包含可以执行的语句序列。执行过程从方法体的第一条语句开始,一直到整个方法结束。
int MyMethod(int par1, string par2) //方法头
{
Console.WriteLine("First"); //方法体
}
局部变量
局部变量和字段一样也保存数据。字段通常保存和对象状态有关的数据,而创建局部变量经常是用于保存局部的或临时的计算数据。
字段实例 | 局部变量 | |
---|---|---|
生存期 | 从实例被创建时开始,直到实例不再被访问时结束 | 从它在块中被声明的那一刻开始,在块完成执行时结束 |
隐式初始化 | 初始化成该类型的默认值 | 没有隐式初始化。如果变量在使用之前没有被赋值,编译器就会产生一条错误信息。 |
存储区域 | 由于实例字段是类的成员,所以所有字段都存储在堆里,无论他们是值类型还是引用类型。 | 引用类型:引用存储在栈里,数据存储在堆里; 值类型:存储在栈里 |
var 关键字
使用var关键字来声明局部变量,其结果依然是一个类型确定的局部变量。唯一的区别是,不再明确写出类型信息,而是由编译器根据变量的赋值在编译时推断出来。
使用 var 关键字有一些重要条件:
- 只能用于局部变量,不能用于字段;
- 只能在变量声明中包含初始化时使用;
- 一旦编译器推断出变量的类型,它就是固定且不能更改的。
局部常量
局部常量很像局部变量,只是一旦被初始化,它的值就不能改变了。如同局部变量,局部常量必须声明在块的内部。
常量的两个最重要的特征如下:
- 在声明时必须初始化。
- 在声明后不能改变。
关键字:const
const TYPE Identifier = Value;
局部函数
从 C#7.0 开始,你可以在一个方法中声明另一个单独的方法。
参数
引用类型的实参,实参和形参都引用堆中的同一个对象,执行方法结束后,他的值被方法的行为改变了。
值类型的实参,系统在栈上为形参分配内存,执行方法结束后,形参从栈中弹出,他的值不受方法行为的影响。
引用参数
- 使用引用参数时,必须在方法的声明和调用中都使用 ref 修饰符。
- 实参必须是变量,在用作实参前必须被赋值。如果是引用类型变量,可以赋值为一个引用或 null。
void MyMethod( ref int val) //方法声明
{...}
int y = 1; //实参变量
MyMethod( ref y) //方法调用
MyMethod( ref 3+5 ) //出错了,没有使用变量
引用参数具有的特征:
- 不会再栈上为形参分配内存。
- 形参的参数名将作为实参变量的别名,指向相同的内存位置。
由于形参名和实参名指向相同的内存位置,所以再方法的执行过程中对形参做的任何改变在方法完成后依然可见。
引用类型作为值参数和引用参数
- 将引用类型对象作为值参数传递:如果在方法内创建一个新对象并赋值给形参,将切断形参与实参之间的关联,并且在方法调用结束后,新对象也将不复存在。
- 将引用类型对象作为引用参数传递:如果在方法内创建一个新对象并赋值给形参,在方法结束后该对象依然存在,并且是实参所引用的值。
第一种情况 将引用类型对象作为值参数传递
class Program
{
static void Main(string[] args)
{
MyClass a1 = new MyClass();
Console.WriteLine(a1.Val);
Change(a1);
Console.WriteLine(a1.Val);
}
static void Change(MyClass f1)
{
f1.Val = 50;
Console.WriteLine(f1.Val);
f1 = new MyClass();
Console.WriteLine(f1.Val);
}
}
class MyClass
{
public int Val = 20;
}
输出结果:
20
50
20
50
下图阐明了关于上述代码的以下几点:
- 在方法开始时,实参和形参都指向堆中相同的对象。
- 在为对象的成员赋值之后,它们仍指向堆中相同的对象。
- 当方法分配新的对象并赋值给形参时,(方法外部的)实参仍指向原始对象,而形参指向的是新对象。
- 在方法调用之后,实参指向原始对象,形参和新对象都会消失。
class Program
{
static void Main(string[] args)
{
MyClass a1 = new MyClass();
Console.WriteLine(a1.Val);
Change(ref a1);
Console.WriteLine(a1.Val);
}
static void Change(ref MyClass f1)
{
//设置对象成员
f1.Val = 50;
Console.WriteLine(f1.Val);
//创建新对象并赋值给形参
f1 = new MyClass();
Console.WriteLine(f1.Val);
}
}
class MyClass
{
public int Val = 20;
}
输出结果:
20
50
20
20
下图阐明了上述代码的以下几点:
- 在方法调用时,形参和实参都指向堆中相同的对象
- 对成员值的修改会同时影响到形参和实参。
- 当方法创建新的对象并赋值给形参时,形参和实参的引用都指向该新对象。
- 在方法结東后,实参指向在方法内创建的新对象。
输出参数
输出参数用于从方法体内把数据传出到调用代码,他们的行为与引用参数类似。
输出参数有以下要求:
- 必须在声明和调用中都是用修饰符。输出参数的修饰符是 out 。
- 实参必须是变量,而不能是其他类型的表达式。
- 在方法内部,给输出参数赋值之后才能读取它。这意味着参数的初始值是无关的,而且没有必要在方法调用之前为实参赋值。
- 在方法内部,在方法返回之前,代码中每条可能的路径都必须为所有输出参数赋值。
与引用参数类似,输出参数的形参充当实参的别名。形参和实参都是同一块内存位置的名称。在方法内对形参做的任何改变在方法执行完成之后(通过实参变量)都是可见的。
如果方法中有任何执行路径试图在输出参数被方法赋值之前读取它,编译器就会产生一条错误信息。
public void Add2 ( out int outValue )
{
int var1 = outValue + 2; //错误!在方法赋值之前,无法读取输出变量
}
例如,下面的代码在此展示了方法MyMethod,但这次使用输出参数。
class Program
{
static void Main(string[] args)
{
MyClass a1 = null;
int a2;
MyMethod(out a1, out a2);//调用方法
}
static void MyMethod(out MyClass f1,out int f2)
{
f1 = new MyClass();//创建一个类变量
f1.Val = 25;//赋值类的字段
f2 = 15;//赋值int参数
}
}
class MyClass
{
public int Val = 20;//初始化字段为20
}
下图阐述了在方法执行的不同阶段中实参和形参的值:
- 在方法调用之前,将要被用作实参的变量a1和a2已经在栈里了。
- 在方法的开始,形参的名称设置为实参的别名。你可以认为变量a1和f1指向的是相同的内存位置,也可以认为a2和f2指向的是相同的内存位置。a1和a2不在作用域之内,所以不能在 MyMethod中访问。
- 在方法内部,代码创建了一个MyClass类型的对象并把它赋值给f1,然后赋一个值给f1的字段,也赋一个值给f2。对f1和f2的赋值都是必需的,因为它们是输出参数。
- 方法执行之后,形参的名称已经失效,但是引用类型的a1和值类型的a2的值都被方法内的行为改变了。
对于输出参数,形参就好像是实参的别名一样,但是还有一个需求,那就是它必须在方法内进行赋值
从 C#7.0 开始,你不在需要预先成名一个变量来用作 out 参数了。你可以在调用方法时在参数列表中添加一个变量类型,它将作为变量声明。
- 消除显式的变量声明;
- 直接在方法调用时加入变量类型声明;
例如:
static void Main()
{
MyMethod(out MyClass a1, out int a2); //调用方法
}
虽然 a1 和 a2 只在方法调用语句中进行了声明,但它们也可以在方法调用完后继续使用。
参数数组
参数数组允许特定类型的零个或多个实参对应一个特定的形参。
参数数组的重点如下:
- 在一个参数列表中只能有一个参数数组;
- 如果有,它必须是列表中的最后一个;
- 由参数数组表示的所有参数必须是同一类型;
声明一个参数数组时必须做的事如下: - 在数据类型前使用 params 修饰符。
- 在数据类型后放置一组空的方括号。
例:
void ListInts( params int[] inVals){ }
- 数组是个引用类型,因此它的所有数据项都保存在堆中。
方法调用
//方式一:
1. ListInt(1,2,3); //3个int
//方式二:
2. int[] intArray = {1,2,3};
ListInt(intArray);//一个数组变量
调用时不允许有 params 修饰符。
当数组在堆中被创建时,实参的值被复制到数组中,这样它们就像值参数。
- 如果数组参数是值类型,那么值被复制,实参在方法内部不受影响。
- 如果数组参数是引用类型,那么引用被复制,实参引用的对象在方法内部会受到影响。
在方法调用之前创建并组装一个数组,把单一的数组变量作为实参传递。这种情况下编译器使用你的数组而不是重新创建一个。
参数类型总结
参数类型 | 修饰符 | 是否在声明时使用 | 是否在调用时使用 | 执行 |
---|---|---|---|---|
值 | 无 | 系统把实参的值复制到形参 | ||
引用 | ref | 是 | 是 | 形参是实参的别名 |
输出 | out | 是 | 是 | 仅包含一个返回的值,形参是实参的别名 |
数组 | params | 是 | 否 | 允许传递可变数目的实参到方法 |
ref 局部变量和 ref 返回
ref 局部变量功能的重要事项:
- 你可以使用这个功能创建一个变量的别名,即使引用的对象是值类型。
- 对任意一个变量的赋值都会反映到另一个变量上,因为他们引用的是相同的对象,即使是值类型。
例:
//创建别名的语法需要使用关键字 ref 两次,一次是在别名声明的类型的前面,另一次是在赋值运算符的右边,“被别名”的变量的前面
ref int y = ref x;
ref 返回功能提供了一种方法返回变量引用而不是变量值的方法。这里也使用了 ref 关键字两次:
- 一次是在方法的返回类型声明之前
- 另一次是在 return 关键字之后,被返回对象的变量名之前
class Simple
{
private int Score = 5;
// ref 返回方法的关键字
public ref int RefToValue()
{
//ref 返回方法的关键字
return ref Score;
}
public void Display()
{
Console.WriteLine($"{Score}");
}
}
class Program
{
static void Main(string[] args)
{
Simple s = new Simple();
s.Display();//Value inside class object: 5
//此时返回的是成员在内存上的引用地址, 而不单纯是值
ref int v1OutSide = ref s.RefToValue();
//在调用域外面修改值, 直接修改了成员的值
v1OutSide = 10;
s.Dsiplay();//Value inside class object: 10
}
}
Math库中的Max方法的变形, 提供两个数字类型的变量, Math.Max能够返回两个值中比较大的那个, 但是假设你想返回的是包含较大值的变量的引用, 可以用ref返回
class Program
{
public static ref int Max(ref int p1, ref int p2)
{
if (p1 > p2)
return ref p1;
else
return ref p2;
}
static void Main(string[] args)
{
int v1 = 10;
int v2 = 20;
Console.WriteLine("start");
Console.WriteLine($"v1 = {v1}, v2 = {v2}");// 10, 20
ref int max = ref Max(ref v1, ref v2);
Console.WriteLine("after method");
Console.WriteLine($"max = {max}");//20
max++;
Console.WriteLine("after increment");
Console.WriteLine($"v1 = {v1}, v2 = {v2}");// 10, 21
}
}
注意:
- ref return 表达式不能返回如下内容:
- 空值
- 常量
- 枚举成员
- 类或者结构体的属性
- 指向只读位置的指针
- ref return 表达式只能指向原先就在调用域内的位置,或者字段。所以,他不能指向方法的局部变量。
- ref 局部变量只能被赋值一次,也就是说,一旦初始化,他就不能指向不同的存储位置了。
- 即使将一个方法声明为 ref 返回方法,如果在调用该方法时省略了 ref 关键字,则返回的将是值,而不是指向值的内存位置的指针。
- 如果将 ref 局部连梁作为常规的实际参数传递给其他方法,则该方法仅获取该变量的一个副本。尽管 ref 局部变量包含指向存储位置的指针,但是当以这种方式使用时,他会传递值而不是引用。
方法重载
一个类中可以有多个同名方法,这叫作方法重载(method overloading)。使用相同名称的每个方法必须有一个和其他方法不同的签名(signature)。
- 方法的签名由下列信息组成,他们在方法声明的方法头中:
- 方法的名称;
- 参数的数目;
- 参数的数据类型和顺序;
- 参数修饰符;
- 返回类型和形参的名称不是签名的一部分。
命名参数
C#允许我们使用命名参数(named parameter)。只要显式指定参数的名字,就可以以任意顺序在方法调用中列出实参。
- 方法的声明没有什么不一样。形参已经有名字了。
- 不过在调用方法的时候,形参的名字后面跟着冒号和实际的参数值或表达式。
例:
public static int Calc(int a,int b,int c){...};
static Main()
{
Calc(c:2,a:4,b:3);
}
可选参数
所谓可选参数就是可以在调用方法的时候包含这个参数,也可以省略它。
为了表明某个参数是可选的,你需要在方法声明中为该参数提供默认值。
例:
// 参数b为可选参数,默认值为3
public int Calc(int a, int b = 3){...}
可选参数声明的注意事项:
- 不是所有的参数类型都可以作为可选参数。
- 只要值类型的默认值在编译的时候可以确定,就可以使用值类型作为可选参数。
- 只有在默认值是 null 的时候,引用类型才可以用作可选参数。
- 所有必填参数(required parameter) 必须在可选参数声明之前声明。如果有 params 参数,必须在所有可选参数之后声明。
语法顺序:必填参数-可选参数-params参数
栈帧
在调用方法的时候,内存从栈的顶部开始分配,保存和方法关联的一些数据项。这块内存叫作方法的栈帧(stack frame)。
栈帧包含的内存保存如下内容:
- 返回地址,也就是在方法退出的时候继续执行的位置。
- 分配内存的参数,也就是方法的值参数,还可能是参数数组(如果有的话)。
- 和方法调用相关的其他管理数据项。
在方法调用时,整个栈帧都会压入栈。
在方法退出的时候,整个栈帧都会从栈上弹出。弹出栈帧有的时候也叫作栈展开(unwind)。
递归
调用方法自身的机制,每一次方法调用把新的栈帧压入栈顶。
6.深入理解类
成员修饰符的顺序
类成员声明语句由下列部分组成:核心声明、一组可选的修饰符和一组可选的特性(attribute)。
[特性] [修饰符] 核心声明
方括号表示方括号内的成分是可选的。
- 修饰符
- 如果由修饰符,必须放在核心声明之前。
- 如果有多个修饰符,可以任意顺序排列。
- 特性
- 如果有特性,必须放在修饰符和核心声明之前。
- 如果有多个特性,可以任意顺序排列。
静态字段
除了实例字段,类还可以拥有静态字段。
- 静态字段被类的所有实例共享,所有实例都访问同一内存位置。因此,如果该内存位置的值被一个实例改变了,这种改变对所有的实例都可见。
- 可以使用 static 修饰符将字段声明为静态
可以通过类名.静态成员名
的方式从类的外部访问静态成员。
静态成员的生存期与实例成员不同,即使类没有实例,也存在静态成员,并且可以访问。
除了静态字段,还有静态函数成员。
- 如同静态字段,静态函数成员独立于任何类实例,没有类的实例也能调用。
- 静态函数成员不能访问实例成员,但能访问其他静态成员。
可以声明为static的类成员类型包括:
- 字段
- 类型
- 方法
- 属性
- 构造函数
- 运算符
- 事件
不可以声明为static的类成员类型包括: - 常量
- 索引器
成员常量
成员常量和局部常量类似,只是它们被声明在类声明中而不是方法内。
需要在声明时赋值,之后不能改变。
例:
class MyClass
{
const int IntVal = 100; //成员常量
public void MyMethod()
{
const int PartIntVal = 10; //局部常量
}
}
成员常量对类的每个实例都是“可见的”,而且即使没有类的实例也可以使用。常量 const 没有自己的存储位置,而是在编译时被编译器替换。这种方式类似是C/C++中的#define值。
属性
属性是代表类实例或类中的数据项的成员。使用属性就像写入或读取一个字段,语法相同。
与字段类似,属性有以下特征:
- 它是命名的类成员。
- 他又类型。
- 它可以被赋值和读取。
与字段的不同,属性是一个函数成员。 - 它不一定为数据存储分配内存
- 他执行代码。
属性是一组(两个)匹配的、命名的、称为访问器的方法。 - set 访问器为属性赋值
- get 访问器从属性获取值
访问器
set 访问器总是:
- 拥有一个单独的、隐式的值参,名称为value,与属性的类型相同。
- 拥有一个返回类型 void
get 访问器总是: - 没有参数
- 拥有一个与属性类型相同的返回类型
C#7.0 为属性的 getter/setter 引入了另一种语法,这种语法使用表达函数体(lambda表达式)。
int MyValue
{
set => value > 100 ? 100:value;
get => theRealValue;
}
属性和字段的命名约定
第一种约定:两个名称使用相同的内容,但字段使用 Camel 大小写,属性使用 Pascal 大小写。
private int firstField; //Camel
public int FirstField {get;set;}//Pascal
第二种约定:属性使用 Pascal 大小写,字段使用相同标识符的Camel 大小写版本,并以下划线开始。
private int _secondField; //下划线开始 Camel
public int SecondField {get;set;}
属性与公有字段
属性比公有字段更好,理由如下:
- 属性是函数成员而不是数据成员,允许你处理输入和输出,而公有字段不行。
- 属性可以只读或只写,而字段不行。
- 编译后的变量和编译后的属性语义不同。
自动实现属性
因为属性经常被关联到后备字段,所以 C# 提供了自动实现属性这个特性,允许只声明属性而不生命后备字段。
自动实现属性的要点:
- 不声明后备字段:编译器根据属性的类型分配内存。
- 不能提供访问器的方法体:它们必须被简单地声明为分号。get 担当简单的内存读,set 担当简单的写。
class C1
{
public int MyValue
{
get;set;
}
}
静态属性
属性也可以声明为 static。静态属性的访问器和所有静态成员一样,具有以下特点:
- 不能访问类的实例成员,但能被实例成员访问。
- 不管类是否有实例,它们都是存在的。
- 在类的内部,可以仅使用名称来引用静态属性。
- 在类的外部,可以通过类名或者使用 using static 结构来引用属性。
实例构造函数
- 构造函数用于初始化类实例的状态。
- 如果希望能从类的外部创建类的实例,需要将构造函数声明为 public。
- 构造函数的名称和类名相同。
- 构造函数不能有返回值。
- 构造函数可以带参数。
- 构造函数可以被重载。
例:
class MyClass
{
//构造函数
public MyClass()
{
...
}
}
默认构造函数
如果在类的声明中没有显式的提供实例构造函数,那么编译器会提供一个隐式的默认构造函数,它没有参数,方法体为空。
如果你声明了任何构造函数,那么编译器将不会为该类定义默认构造函数。所以此时如果使用无参的构造函数创建新实例,编译器会报错。
静态构造函数
构造函数也可以声明为 static。实力构造函数初始化类的每个新实例,而 static 构造函数初始化类级别的项。通常静态构造函数初始化类的静态字段。
- 初始化类级别的项。
- 在引用任何静态成员之前。
- 在创建类的任何实例之前。
- 静态构造函数在以下方面与实例构造函数类似
- 静态构造函数的名称必须和类名相同。
- 不能有返回值
- 静态构造函数在以下方面和实例构造函数不同。
- 静态构造函数声明中使用 static 关键字。
- 类只能有一个静态构造函数,而且不能带参数。
- 静态构造函数不能有访问修饰符。
例:
class C1
{
private static Random RandomKey; //私有静态字段
//静态构造函数
static C1()
{
RandomKey = new Random(); //执行所有静态初始化
}
public int GetRandomNumber()
{
return RandomKey.Next();
}
}
- 类既可以有静态构造也可以有实例构造。
- 如同静态方法,静态构造函数不能访问所在类的实例成员,因此也不能使用 this 访问器。
- 不能从程序中显式调用静态构造函数,系统会自动调用它们:
- 在类的任何实例被创建之前。
- 在类的任何静态成员引用之前。
readonly 修饰符
字段可以用 readonly 修饰符声明。起作用类似于将字段声明为 const,一旦值被设定就不能改变。
- const 字段只能在字段的声明语句中初始化,而 readonly 字段可以在下列任意位置设置它的值。
- 字段声明语句,类似于 const。
- 类的任何构造函数。如果是 static 字段,初始化必须在静态构造函数中完成。
- const 字段的值必须可在编译时决定,而 readonly 字段的值可以在运行时决定。
- const 的行为总是静态的,而对于 readonly 字段以下两点是正确的。
- 它可以是实例字段,也可以是静态字段。
- 他在内存中有存储位置。
this 关键字
this 关键字在类中使用,是对当前实例的引用。
他只能被用于在下列类成员的代码块中。
- 实例构造函数
- 实例方法
- 属性和索引器的实例访问器。
this 的作用:
- 用于区分类的成员和局部变量或参数
- 作为调用方法的实参
索引器
索引器是一组 get 和 set 访问器,与属性类似。索引器使用索引运算符,它有一对方括号和中间的索引组成。
索引器和属性
相似点:
- 和属性一样,索引器不用分配内存来存储。
- 索引器和属性都主要被用来访问其他数据成员,它们与这些成员关联,并为它们提供获取和设置访问。
- 属性通常表示单个数据成员。
- 索引器通常表示多个数据成员。
索引器的注意点:
- 和属性一样,索引器可以只有一个访问器,也可以两个多有。
- 索引器总是实例成员,因此不能被声明为 static。
- 和属性一样,实现get 和 set 访问器的代码不一定要关联到某个字段和属性。这段代码可以做任何事情也可以什么都不做,只要 get 访问器返回某个指定类型的值即可。
声明索引器
声明索引器的语法如下。
- 索引器没有名称,在名称的位置是 this 关键字。
- 参数列表在方括号中间。
- 参数列表中必须至少声明一个参数。
ReturnType this [Type param1,...]
{
get
{
...
}
set
{
...
}
}
示例:
public class Employee
{
public string LastName;
public string FirstName;
public string CityOfBirth;
public string this[int index]
{
set
{
switch (index)
{
case 0: LastName = value;
break;
case 1: FirstName = value;
break;
case 2: CityOfBirth = value;
break;
default:
throw new ArgumentOutOfRangeException("index");
}
}
get
{
switch (index)
{
case 0: return LastName;
case 1: return FirstName;
case 2: return CityOfBirth;
default:
throw new ArgumentOutOfRangeException("index");
}
}
}
}
索引器重载
只要索引器的参数列表不同,类就可以有任意多个索引器。
例:
class MyClass
{
public string this[int index]
{
get{ }
set{ }
}
public string this[int index1,int index2]
{
get{ }
set{ }
}
public int this[float index1]
{
get{ }
set{ }
}
}
访问器的访问修饰符
默认情况下,成员的两个访问器的访问级别和成员自身相同,如果一个属性的访问级别是public ,那么它的两个访问器的访问级别也是如此。
访问器的访问修饰符有几个限制:
- 仅当成员(属性或索引器)既有get访问器也有 set 访问器时,其访问器才能有访问修饰符。
- 虽然两个访问器都必须出现,但它们中只能有一个有访问修饰符。
- 访问器的访问修饰符的限制必须比成员的访问级别更严格。
分部类和分部类型
类的声明可以分割成几个分部类的声明。
- 每个分部类的声明都含有一些类成员的声明。
- 类的分部类声明可以在同一个文件中也可以在不同的文件中。
每个分部类声明必须标注为 partial class,而不是单独的关键字 class。
类型修饰符 partial 不是关键字,所以在其他上下文中,可以在程序中把它用作标识符。但直接用在关键字 class、struct 或 interface 之前时,他表示分部类型。
分部方法
分部方法是声明在分部类中不同部分的方法。分部方法的不同部分也可以声明在分部类的不同部分中,也可以声明在同一个部分中。
分部方法的两个部分如下:
- 定义分部方法声明
- 给出签名和返回类型。
- 声明的实现部分只是一个分号。
- 实现分部方法声明
- 给出签名和返回类型
- 以普通的语句块形式实现。
分部方法的注意点:
- 定义声明和实现声明的签名和返回类型必须匹配。签名和返回类型有如下特征:
- 返回类型必须是void
- 签名不能包括访问修饰符,这使分部方法是隐式私有的。
- 参数列表不能包含 out 参数。
- 在定义声明和实现声明中都必须包含上下文关键字 partial,并且直接放在关键字 void 之前。
- 可以有定义部分而没有实现部分。
7. 类和继承
类继承
通过继承可以定义一个新类,新类纳入一个已经声明的类并进行扩展。
- 可以使用一个已经存在的类作为新类的基础。已存在的类称为基类(base class),新类称为派生类( derived class)。派生类成员的组成如下:
- 本身声明的成员
- 基类的成员
- 要声明一个派生类,需要在类名后加入基类规格说明。基类规格说明由冒号和用作基类的类名称组成。派生类直接继承自列出的基类。
- 派生类扩展它的基类,因为它包含了基类的成员,还有它本身声明中的新增功能。
- 派生类不能删除它所继承的任何成员。
例:
class OtherClass : SomeClass
{
...
}
除了特殊的类 object,所有的类都是派生类。即使天赋没有基类规格说明。类 object 是唯一的非派生类,因为它是继承层次结构的基础。
类继承的注意点:
- 一个类声明的基类规格说明中只能由一个单独的类。这称为单继承。
- 虽然类只能直接继承一个基类,但派生的层次没有限制。也就是说,作为基类的类可以派生自另外一个类,而这个类又派生自另外一个类…,直至最终到达 object。
屏蔽基类的成员
虽然派生类不能删除他继承的任何成员,但可以用与基类成员名称相同的成员来屏蔽(mask)基类成员。
注意点:
- 要屏蔽一个继承的数据成员,需要声明一个新的相同类型的成员,并使用相同的名称。
- 通过在派生类中声明新的带有相同签名的函数成员,可以屏蔽继承的函数成员。签名由名称和参数列表组成,不包括返回类型。
- 要让编译器知道你在故意屏蔽继承的成员,可使用 new 修饰符,否则,程序可以编译成功,但编译器会警告你隐藏了一个继承的成员。
- 也可以屏蔽静态成员。
例:
class SomeClass //基类
{
public string Field1;
}
class OtherClass : SomeClass //派生类
{
new public string Field1; //用同样的名称屏蔽基类成员
}
基类访问
如果派生类必须访问被隐藏的继承成员,可以使用基类访问(base access)表达式。基类访问表达式由关键字 base 后面跟着一个点和成员的名称组成,如下所示:
Console.WriteLine("{0}",base.Field1);
使用基类的引用
派生类的实例由基类的实例和派生类新增的成员组成。派生类的引用指向整个类对象,包括基类部分。
使用类型转换运算符可以把该引用转换为基类类型。
MyDerivedClass derived = new MyDerivedClass(); //创建一个对象
MyBaseClass mybc = (MyBaseClass) derived; //转换引用
- 基类部分的引用“看不到”派生类对象的其余部分,因为它通过基类类型的引用“看”这个对象。
虚方法和覆写方法
虚方法可以使基类的引用访问“升至”派生类内。
可以使用基类引用调用派生类的方法,只需满足下面的条件。
- 派生类的方法和基类的方法由相同的签名和返回类型。
- 基类的方法使用 virtual 标注。
- 派生类的方法使用 override 标注。
例:
class MyBaseClass
{
virtual public void Print()
{
...
}
}
class MyDerivedClass : MyBaseClass
{
override public void Print()
{
...
}
}
注意:
- 覆写和被覆写的方法必须有相同的可访问性。
- 不能覆写 static 方法或非虚方法。
- 方法、属性和索引器,以及另一种成员类型——事件,都可以被声明为 virtual 和 override。
类访问修饰符
类的可访问性有两个级别:public 和 internal
- 标记为 public 的类可以被系统内任何程序集中的代码访问。
- 标记为 internal 的类只能被它自己所在的程序集内的类看到。
- 这是默认的可访问级别。
程序集间的继承
C#允许从一个在不同的程序集内定义的基类来派生类。
要从不同的程序集中定义的基类派生类,必须具备以下条件。
- 基类必须被声明为 public ,这样才能从它所在的程序集外部访问它。
- 必须在 Visual Studio 工程中的 References 节点中添加对包含该基类的程序集的引用。
成员访问修饰符
5个成员访问级别:
- public:公有成员的可访问性,限制最少。
- private:私有成员的可访问性,访问级别最严格,只能被它自己的类的成员访问。不能被其他的类访问,包括继承它的类。
- protected:受保护成员的可访问性,访问级别如同 private,但它允许派生自该类的类访问该成员。即使程序集外部继承该类的类也能访问该成员。
- internal:内部成员的可访问性,对程序集内部的所有类可见,但对程序集外部的类不可见。
- protected internal:受保护内部成员的可访问性,对所有继承该类的类以及程序集内部的所有类可见。是 protected 和 internal 的并集。
必须对每个成员指定成员访问级别。如果不指定它的隐式访问级别是 private。
成员的可访问性不能比它的类高。
成员可访问性总结
抽象成员
抽象成员是指被设计为被覆写的函数成员。
抽象成员有以下特征:
- 必须是一个函数成员。不能是字段和常量。
- 必须用 abstract 修饰符标记。
- 不能有实现代码块。
例:
abstract public void PrintStuff(string s); //抽象方法
abstract public int MyProperty //抽象属性
{
get;
set;
}
抽象方法只可以在抽象类中声明。以下4种类型可以声明为抽象的:
- 方法
- 属性
- 事件
- 索引器
派生类中抽象成员的实现必须指定 override 修饰符。
抽象类
抽象类是指设计为被继承的类。
抽象类只能被用作其他类的基类。
- 不能创建抽象类的实例。
- 抽象类使用 abstract 修饰符声明。
- 抽象类可以包含抽象成员或普通的非抽象成员。
- 抽象类自己可以派生自另一个抽象类。
- 任何派生自抽象类的类必须使用 override 关键字实现该类所有的抽象成员,除非派生类自己也是抽象类。
密封类
密封类与抽象类相反,不能用作基类,可以被实例化。
- 密封类只能用作独立的类,他不能用作基类。
- 密封类使用 sealed 修饰符标注。
例:
sealed class MyClass
{
...
}
静态类
静态类中所有成员都是静态的。静态类用于存放不受实例数据影响的数据和函数。
静态类的一个常见用途可能是创建一个包含一组数学方法和值的数学库。
- 类本身必须标记为 static。
- 类的所有成员必须是静态的
- 类可以有一个静态构造函数,但不能有实例构造函数,
- 静态类是隐式密闭的,不能继承静态类
扩展方法
扩展方法的要求如下:
- 声明扩展方法的类必须声明为 static
- 扩展方法本身必须声明为 static
- 扩展方法必须包含关键字 this 作为它的第一个参数类型,并在后面跟着它扩展的类的名称。
例:
//必须是静态类
static class ExtendMyData
{
//必须是公有和静态
public static double Acerage(this MyData md)
{
...
}
}
8. 表达式和运算符
9. 语句
using 语句
某些类型的非托管对象有数量限制或很耗费系统资源。在代码使用完它们后,尽快释放它们是非常重要的。
using 语句有助于简化该过程并确保这些资源被适当地处置。
资源是指实现了 System.IDisposable 接口的类或结构。IDisposable 接口含有一个名称为 Dispose 的方法。
包装资源的使用
using 语句帮助减少意外的运行时错误带来的潜在的问题, 它整洁地包装了资源的使用。
有两种形式地 using 语句。
第一种形式如下:
using (ResourceType IDentifier = Expression) Statement
(ResourceType IDentifier = Expression)
:圆括号内的代码分配资源。Statement
:是使用资源的代码。- using 语句隐式产生处置该资源地代码。
意外地运行时错误称为异常,可能产生异常的代码放在try块中,无论有没有异常都必须执行的代码放进 finally 块中。
这种形式的 using 语句确实是这么做的。
- 分配资源
- 把 statement 放进 try 块
- 创建资源的 Dispose 方法的调用,并把它放进 finally 块中。
例:
static void Main(string[] args)
{
//打开一个文本文件, 并向其中写入一行
using (TextWriter tw = File.CreateText("Lincoln.txt"))
{
tw.WriteLine("Four score and seven years ago,...");
}
//打开相同的文本文件, 一行一行读取并显示它的内容
using (TextReader tr = File.OpenText("Lincoln.txt"))
{
string InputString;
while((InputString = tr.ReadLine()) != null)
{
Console.WriteLine(InputString);
}
}
}
多个资源和嵌套
using 语句还可以用于相同类型的多个资源,资源声明用于逗号隔开。
语法如下:
using (ResourceType Id1 = Expr1, Id2 = Expr2, ...) EmbeddedStatement
例:
static void Main()
{
using (TextWriter tw1 = File.CreateText("Lincoln.txt"),
tw2 = File.CreateText("Franklin.txt"))
{
tw1.WriteLine("Four score and seven years ago,...");
tw1.WriteLine("Early to bed; Early to rise ...");
}
using (TextReader tr1 = File.OpenText("Lincoln.txt"),
tr2 = File.OpenText("Franklin.txt"))
{
string InputString;
while ((InputString = tr1.ReadLine()) != null)
{
Console.WriteLine(InputString);
}
while ((InputString = tr2.ReadLine()) != null)
{
Console.WriteLine(InputString);
}
}
}
using 语句还可以嵌套。简单语句还可以省略块。
using (TextWriter tw1 = File.CreateText("Lincoln.txt"))
{
tw1.WriteLine("Four score and seven years ago,...");
using (tw2 = File.CreateText("Franklin.txt")) //嵌套语句
tw1.WriteLine("Early to bed; Early to rise ..."); //简单语句,省略了块
}
using 语句的另一种形式
资源在 using 语句之前声明。
using (Expression) EmbeddedStatement
例:
TextWriter tw = File.CreateText("Lincoln.txt"); //声明资源
using (tw) //using 语句
tw.WriteLine("Four score and seven years age, ...");
虽然这种形式也能确保使用完资源后总是调用 Dispose 方法,但他不能防止在 using 语句已经释放了它的非托管资源之后使用该资源,这可能会导致状态不一致。因此它提供了较少的保护,不推荐使用。
10. 结构
结构是程序员定义的数据类型,与类非常类似。它们有数据成员和函数成员。虽然与类相似,但是结构有许多重要的区别:
- 类是引用类型,而结构是值类型
- 结构是隐式封闭的,这意味着不能从它们派生其他结构。
关键字:struct
声明语法:
struct StructName
{
MemberDeclarations
}
在声明结构体时,不允许使用实例属性和字段初始化语句,但是静态属性和静态字段都可以在声明时初始化,即使结构体不是静态的。
因为结构是值类型,和所有值类型一样,结构类型变量含有自己的数据。因此:
- 结构类型的变量不能为 null;
- 两个结构变量不能引用同一对象。
类和结构在内存的安排
//类
class CSimple
{
public int X;
public int Y;
}
//结构
struct Simple
{
public int X;
public int Y;
}
class Program
{
static void Main()
{
CSimple cs = new CSimple();
Simple ss = new Simple();
...
}
}
对结构和类赋值的区别
- 把一个结构赋值给另一个结构,就是将一个结构的值复制给另一个结构。
- 把一个类赋值给另一个类,就是将两个类指向堆中同一对象。
构造函数和析构函数
结构可以有实例构造函数和静态构造函数,但不允许有析构函数。
实例构造函数
对于每个结构,都存在预定义的无参构造函数,而且不能删除或重定义。
但是可以创建有参构造函数。
静态构造函数
与类相似,结构的静态构造函数创建并初始化静态数据成员,而且不能引用实例成员。结构的静态构造函数和类的静态构造函数的规则一样,但允许有不带参数的静态构造函数。
以下两种行为,任意一种发生之前,将会调用静态构造函数。
- 调用显式声明的构造函数
- 引用结构的静态成员
11. 枚举
枚举是由程序员定义的类型,与类和结构一样。
- 与结构一样,枚举是值类型,直接存储它们的数据,而不是分开存储成引用和数据。
- 枚举只有一种类型的成员:命名的整数值常量。
关键字:enum
例:
enum TrafficLight
{
Green,
Yellow,
Red
}
每个枚举类型都有一个底层整数类型,默认为 int。
- 每个枚举成员都被赋予一个底层类型的常量值。
- 在默认情况下,编译器对第一个成员赋值为 0,对每一个后续成员赋的值都比前一个成员多 1.
枚举只有单一的成员类型:声明的成员常量。
- 不能对成员使用修饰符。它们都隐式地具有和枚举相同的可访问性。
- 由于成员是静态的,即使在没有该枚举类型的变量时也可以访问它们。
设置底层类型和显式值
可以把冒号和类型名放在枚举之后,这样就可以使用 int 以外的整数类型。类型可以是任何整数类型。
在枚举声明中的变量名之后使用初始化表达式,可以显式地设置一个成员的值。
成员的名称不能重复,但是值可以重复。
enum TrafficLight : ulong
{
Green = 10,
Yellow = 15, //重复的值
Red = 15 //重复的值
}
位标志
标志字:程序员们长期使用单个字(single world)的不同位作为表示一组开/关标志的紧凑方法。
实现步骤:
- 确定需要多少个位标志,并选择一种有足够多位的无符号类型保存它。
- 确定每个位位置代表什么,并给它们一个名称。声明一个选中的整数类型的枚举,每个成员由一个位位置表示。
- 使用按位或(OR)运算符在持有该位标志的字中设置适当的位。
- 使用按位与(AND)运算符或 HasFlag 方法检查是否设置了特定位标志。
例:
[Flags]
enum CardDeckSettings : uint
{
SingleDeck = 0x01, //位0
LargePictures = 0x02, //位1
FancyNumbers = 0x04, //位2
Animation = 0x08 //位3
}
枚举类型 标志字 位标志被“或”在一起
↓ ↓ ↓
CardDeckSettings ops = CardDeckSettings.SingleDeck
| CardDeckSettings.FancyNumbers
| CardDeckSettings.Animation;
判断标志字是否包含特定的位标志集,可以使用枚举类型的 HasFlag 布尔方法。
bool useFancyNumbers = ops.HasFlag(CardDeckSettings.FancyNumbers);
12. 数组
Clone 方法
Clone 方法为数组进行浅复制,他只创建了数组本身的克隆。
- 克隆值类型数组会产生两个独立数组。
- 克隆引用类型数组会产生指向相同对象的两个数组。
Clone 方法返回 object 类型的引用,他必须被强制转换成数组类型。
int[] intArr1 = {1,2,3};
int[] intArr2 = (int[]) intArr1.Clone();
13. 委托
委托和类一样,是一种用户定义类型。但类表示的是数据和方法的集合,而委托则持有一个或多个方法,以及一系列预定义操作。
关键字:delegate
声明委托:
//声明
delegate void MyDel(int x);
//创建委托对象
MyDel delVar;
delVar = new MyDel(myInstObj.MyM1); //创建委托并保存引用
- 还可以使用快捷语法
delVar = myInstObj.MyM1
,相同的作用。
委托使用步骤
- 声明一个委托类型。委托声明看上去和方法声明相似,只是没有实现块。
- 使用该委托类型声明一个委托变量。
- 创建一个委托类型的对象,并把它赋值给委托变量。新的委托对象包含指向某个方法的引用,这个方法的签名和返回类型必须跟第一步中定义的委托类型一致。
- 你可以选择为委托对象添加其他方法。这些方法的签名和返回类型必须与第一步中定义的委托类型相同。
- 在代码中你可以像调用方法一样调用委托。在调用委托的时候,其包含的每一个方法都会被执行。
委托持有的方法可以来自任何类或结构,只要它们的返回类型和签名匹配。
方法的列表称为调用列表,调用列表中的方法可以是实例方法也可以是静态方法。
调用委托时会执行器调用列表中的所有方法。
为委托添加方法
使用 += 运算符。
MyDel delVar = inst.MyM1; //创建并初始化
delVar += SCL.m3; //增加方法
delVar += X.Act; //增加方法
此时,delVar对象的调用列表里有3个方法分别是inst.MyM1
、SCL.m3
、X.Act
。
为委托移除方法
使用 -= 运算符。
delVar -= SCL.m3; //从委托移除方法
注意事项:
- 如果在调用列表中的方法有多个实例,-= 运算符将从列表最后开始搜索,并且移除第一个与方法匹配的实例。
- 试图删除委托中不存在的方法将无效。
- 试图调用空委托会抛出异常。如果委托列表为空,则委托为 null。
调用带返回值的委托
如果委托有返回值并且在调用列表中有一个以上的方法,会发生下面的情况:
- 调用列表中最后一个方法的值就是委托调用返回的值。
- 调用列表中所有其他方法的返回值都会被忽略。
调用带引用参数的委托
如果委托有引用参数,参数值会根据调用列表中的一个或多个方法的返回值而改变。
在调用委托列表中的下一个方法时,参数的新值(不是初始值)会传给下一个方法。
匿名方法
语法:
匿名方法表达式的语法包含如下组成部分:
- delegate 类型关键字
- 参数列表,如果语句块没有使用任何参数则可以省略。
- 语句块,它包含了匿名方法的代码。
delegate(Parameters){ImplementationCode}
使用匿名方法
如下地方使用匿名方法:
- 声明委托变量时作为初始化表达式。
- 组合委托时在赋值语句的右边。
- 为委托增加事件时在赋值语句的右边。
返回值
匿名方法不会显式声明返回值。实现代码本身的行为必须通过返回一个委托的返回类型相同的值来匹配委托的返回类型。如果委托有 void 类型的返回值,匿名方法就不能返回值。
delegate int OtherDel(int InParam);
OtherDel del = delegate(int x)
{
return x + 20;
}
参数
除了数组参数,匿名方法的参数列表必须在如下3方面与委托匹配:
- 参数数量
- 参数类型及位置
- 修饰符
可以通过使圆括号为空或省略圆括号来简化匿名方法的参数列表,但必须满足以下两个条件: - 委托的参数列表不包含任何 out 参数;
- 匿名方法不适用任何参数。
delegate void SomeDel(int x);
Somedel Sdel = delegate
{
Printmessage();
};
params 参数
如果委托声明的参数列表包含了 params 参数,那么匿名方法的参数列表必须省略 params 关键字。
变量和参数的作用域
参数以及声明在匿名方法内部的局部变量的作用域限制在实现代码的主体之内。
外部变量
与委托的具名方法不同,匿名方法可以访问它们外围作用域的局部变量和环境。
- 外围作用域的变量叫作外部变量(outer variable)
- 用在匿名方法实现代码中的外部变量称为被方法捕获
捕获变量的生命周期的扩展
只要捕获方法是委托的一部分,即使变量已经离开了作用域,捕获的外部变量也会一直有效。
Lambda 表达式
C# 3.0 引入了 Lambda 表达式,简化了匿名方法的语法。
例:
MyDel del = delegate(int x) { return x + 1; }; //匿名方法
MyDel le1 = (int x) => { return x + 1; }; //Lambda 表达式
MyDel le2 = (x) => { return x + 1; }; //Lambda 表达式
MyDel le3 = x => { return x + 1; }; //Lambda 表达式
MyDel le4 = x => x + 1 ; //Lambda 表达式
简化主体:
- 如果只包含一条 return 语句或者一个表达式,他就可以简化成只有这一条语句,而且 return 关键词也可以省略不写。
简化参数列表: - 编译器可以根据 lambda 表达式转化后的类型推断参数类型。lambda 表达式本身没有类型,但可以转换为兼容的委托类型,这样编译器就可以依据转换来推断参数类型。
- 如果 lambda 表达式只有一个参数,并且可以推断出参数类型,那么参数列表的圆括号也可以省略。
事件
语法:
- 访问修饰符 event 委托类型 事件名;
事件的很多部分和委托类似,实际上,事件就像时专门用于某种特殊用途的简单委托。委托和事件的行为之所以相似,是有充分理由的。事件包含了一个私有的委托。
- 事件提供了对它的私有控制委托的结构化访问。你无法直接访问委托。
- 事件中可用的操作比委托更少,对于事件我们只可以添加、删除或调用事件处理程序。
- 事件被触发时,它调用委托来一次调用调用列表中的方法。
发布者和订阅者
当很多程序都有一个共同的需求,即当一个的顶的程序事件发生时,程序的其他部分可以得到该事件已经发生的通知。便可以使用发布者/订阅者模式。
- 发布者(publisher)发布某个事件的类或结构,其他类可以在该事件发生时得到通知。
- 订阅者(subscriber)注册并在事件发生时得到通知的类或结构。
- 事件处理程序(event handler)由订阅者注册到事件的方法,在发布者触发事件时执行。
- 触发(raise)事件 调用(invoke)或触发(fire)事件的术语。当事件被触发时,所有注册到它的方法都会被依次调用。
源代码组件概览
需要在事件中使用的代码有以下 5 部分:
- 委托类型声明。
- 事件处理程序声明。
- 事件声明。
- 事件注册。
- 触发事件的代码。
声明事件
发布者必须提供事件对象,创建对象只需要委托类型和名称。
- 事件声明在一个类中
- 他需要委托类型的名称,任何附加到事件(如注册)的处理程序都必须与委托类型的签名和返回类型匹配。
- 它声明为 public
- 不能使用对象创建表达式(new 表达式)来创建它的对象。
class Incrementer
{
关键字 委托类型 事件名
↓ ↓ ↓
public event EventHandler CountedADozen;
//声明3个事件
public event EventHandler MyEvent1, MyEvent2, MyEvent3;
//静态事件
public static event EventHandler CountedADozen;
}
注意点:
- 事件是成员,和方法、属性一样,事件是类或结构的成员。
- 由于事件是成员,我们不能在一段可执行代码中声明事件,他不许声明在类或结构中,和其他成员一样。
- 事件成员被隐式自动初始化为 null。
订阅事件
要添加到事件的事件处理程序必须具有与事件的委托相同的返回类型和签名。
- 使用
+=
运算符为事件添加事件处理程序。 - 事件处理程序可以是以下任意一种:
- 实例方法:
incrementer.CoutedAdozen += IncrementDozensCount; //方法引用形式
- 静态方法:
incrementer.CoutedAdozen += ClassB.CounterHandlerB; //方法引用形式
- 匿名方法:
incrementer.CoutedAdozen += delegate{ DozensCount++ }
- Lambda 表达式:
incrementer.CoutedAdozen += () => DozensCount++;
事件添加方法有3种形式:实例方法、静态方法、委托形式的实例方法
- 实例方法:
mc.CountedADozen += new EventHandler(cc.CounterHandlerC); //委托形式
和委托一样,可以使用匿名方法和 Lambda 表达式来添加事件处理程序。
触发事件
事件成员本身只是保存了需要被调用的事件处理程序。如果事件不被触发,则什么都不会发生。
- 触发事件之前和 null 比较,如果事件是 null,则表示事件没有事件处理程序,不能执行。
- 触发事件的语法和调用方法一样
if(CountedADozen != null) //确认有方法可以执行
CountedADozen(source, args); //触发事件
15. 接口
声明接口
- 接口声明不能包含数据成员和静态成员,只能包含方法、属性、事件、索引器类型的非静态成员函数。
- 声明不能包含任何实现代码,必须使用分号代替每一个声明的主体。
- 按照惯例,名称必须以大写 I 开始。
- 接口可以是任何访问修饰符,接口成员是隐式 public ,不允许有任何访问修饰符。
接口是引用类型
可以通过把类对象引用强制转换为接口类型来获取指向接口的引用。
有了接口引用就可以使用点语法来调用接口的成员。
例:
IIfc1 ifc = (IIfc1) mc; //获取接口的引用
ifc.PrintOut("interface"); //使用接口的引用调用方法
as 运算符
上边的例子中使用的强制转换运算符来获取对象接口的引用,还可以使用 as 运算符。
例:
IIfc1 ifc = mc as IIfc1; //获取接口的引用
if(ifc != null)
ifc.PrintOut("interface"); //使用接口的引用调用方法
区别:
- 强制转换运算符转换未实现的接口时会抛出一个异常
- as 运算符转换未实现接口时表达式返回 null,不会抛出异常。
多接口
类或结构可以实现任意数量的接口。
16. 转换
is 运算符
is 运算符用来检查转换是否会成功完成。
语法:
Expr is TargetType
如果 Expr 可以通过以下方式成功转换为目标类型,则运算符返回 true:
- 引用转换
- 装箱转换
- 拆箱转换
17. 泛型
泛型(generic)可以让多个类型共享一组代码。泛型允许我们声明类型参数化(type-parameterized)的代码,用不同的类型进行实例化。
C# 提供了5种泛型:类、结构、接口、委托和方法。除了方法是成员,其他都是类型。
泛型类
声明泛型类
和声明普通类型差不多,区别如下:
- 在类名之后放置一组尖括号。
- 在尖括号中用逗号分隔的占位符字符串来表示需要提供的类型。这叫作类型参数。
- 在泛型类声明的主体中使用类型参数来表示替代类型。
class SomeClass<T1,T2>
{
public T1 SomeVar;
public T2 OtherVar;
}
创建构造类型
替代类型参数的真实类型叫作类型实参。
SomeClass<short,int>
尖括号中提供真实类型来替代类型参数。
编译器接受了类型实参并且替换泛型类主体中的相应类型参数,产生了构造类型。
创建变量和实例
SomeClass<short, int> myInst; //分配类变量
myInst = new SomeClass<short, int>(); //分配实例
- 分配类变量时在栈上为 myInst 分配了一个引用,值是 null;
- 分配实例是在堆上把引用赋值给变量。
类型参数的约束
通过约束可以让编译器知道参数类型可以接受哪些类型。只有符合约束的类型才能替代给定类型参数来产生构造类型。
Where 子句
约束使用 where 子句列出。
- 每一个有约束的类型参数都有自己的 where 子句。
- 如果形参有多个约束,它们在 where 子句中使用逗号分隔。
语法:
where TypeParam : constraint, constraint,...
例:
class MyClass<T1,T2,T3> where T2 : Customer where T3 : IComparable
{
...
}
约束类型和次序
约束类型
约束类型 | 描述 |
---|---|
类名 | 只有这个类型的类或从它派生的类才能用作类型实参 |
class | 任何引用类型,包括类、数组、委托和接口都可以用作类型实参 |
struct | 任何值类型都可以用作类型实参 |
接口名 | 只有这个接口或实现这个接口的类型才能用作类型实参 |
new() | 构造函数约束,任何带有无参公共构造函数的类型都可以用作类型实参。 |
次序
最多只能有一个主约束,而且必须放在第一位
可以有任意多的接口名称约束
如果存在构造函数约束,则必须放在最后。
泛型方法
泛型方法可以在泛型和非泛型类以及构造和接口中声明。
声明泛型方法
泛型方法具有类型参数列表和可选的约束。
例:
public void PrintData<S,T>(S p, T t) where S : Person
{
...
}
调用泛型方法
要调用泛型方法,应该在方法调用时提供类型实参
MyMethod<short,int>();
MyMethod<long,int>();
类型推断
编译器可以从方法参数的类型中推断出应用作泛型方法的类型参数的类型。
public void MyMethod<T>(T myVal){...}
int m = 5;
myMethod<int>(m);
MyMethod(m); //从方法参数中推断类型参数
扩展方法和泛型类
扩展方法允许将类中的静态方法关联到不同的泛型类上,还允许我们向调用类结构实例的实例方法一样来调用方法。
和非泛型类一样,泛型类的扩展方法的要求如下:
- 声明扩展方法的类必须声明为 static
- 扩展方法本身必须声明为 static
- 扩展方法必须包含关键字 this 作为它的第一个参数类型,并在后面跟着它扩展的类的名称。
例:
static class ExtendHolder
{
public static void Print<T>(this Holder<T> h)
{
T[] vals = h.GetValues();
}
}
泛型结构
与泛型类相似,泛型结构可以有类型参数和约束。泛型结构的规则和条件与泛型类是一样的。
例:
struct PieceOfData<T>
{
public PieceOfData(T value){ _data = value; }
private T _data;
public T Data
{
get{ return _data; }
set{ _data = value;}
}
}
泛型委托
泛型委托和非泛型委托非常相似,不过类型参数决定了能接受什么样的方法。
- 要声明泛型委托,在委托名称之后、委托参数列表之前的尖括号中放置类型参数列表。
- 注意,有两个参数列表:委托形参列表和类型参数列表
- 类型参数的范围包括:
- 返回类型;
- 形参列表;
- 约束子句;
例:
返回类型 类型参数 委托形参
↓ ↓ ↓
delegate R MyDelegate<T,R>(T value);
泛型接口
泛型接口允许我们编写 形参和接口成员返回类型是泛型类型参数的接口。
例:
interface IMyIfc<T> //泛型接口
{
T ReturnIt(T inValue);
}
class Simple<S> : IMyIfc<S> //泛型类
{
public S ReturnIt(S inValue) //实现泛型接口
{
return inValue;
}
}
泛型接口的实现必须唯一
实现泛型类型接口时,必须保证类型实参的组合不会在类型中产生两个重复的接口。
错误示例:
interface IMyIfc<T> //泛型接口
{
T ReturnIt(T inValue);
}
class Simple<S> : IMyIfc<int>,IMyIfc<S> //错误
{
public int ReturnIt(int inValue) //实现第一个接口
{
return inValue;
}
public S ReturnIt(S inValue) //实现第二个接口
{
return inValue;
}
}
- 对于泛型接口,使用两个相同接口本身没有错,问题在于这样会有潜在冲突。
- 如果把 int 作为类型实参来代替第二个接口中的 S 的话,Simple 可能会有两个相同类型的接口,这是不允许的。
逆变和协变
协变和逆变只能用在接口或者委托中。
协变
官方定义:
能够使用比原始指定的派生类型的派生程度更大(更具体)的类型。
如果存在一个泛型接口Interface<T>,它的泛型参数子类类型 Interface<Chinese> 可以安全地转换为泛型父类类型 Interface<Human>,这个过程就称为协变。
每一个变量都有一种类型,你可以将派生类型的对象赋值给基类型的变量,这叫作赋值兼容性。
class Animal
{
public int NumberOfLegs = 4;
}
class Dog : Animal
{}
class Program
{
static void Main()
{
Animal a1 = new Animal();
Animal a2 = new Dog();
}
}
仅将派生类型用作输出值与构造委托有效性之间的常数关系叫作协变。为了让编译器知道这是我们的期望,必须使用 out 关键字标记委托声明中类型参数。
delegate T Factory<out T>();
- 协变关系允许程度更高的派生类型处于返回及输出位置
逆变
官方定义:
能够使用比原始指定的派生类型的派生程度更新(更抽象)的类型。
如果存在一个泛型接口 IBar<T>,它的泛型参数父类类型 IBar<Human>,可以安全地转换为泛型子类类型 IBar<Chinese>,这种期望传入基类时允许传入派生对象的特性叫作逆变。
逆变的关键字是 in 。
delegate void Action1<in T>(T a);
18. 枚举器和迭代器
枚举器和可枚举类型
使用 foreach 语句
当为数组使用 foreach 语句时,这个语句会依次取出数组中的每一个元素,允许我们读取它的值。
为什么数组可以这么做?原因是数组可以按需提供一个叫作枚举器的对象。枚举器可以依次返回请求的数组中的元素。
对于枚举器的类型而言,必须有一种方法来获取它,获取对象枚举器的方法是调用对象的 GetEnumerator 方法。实现 GetEnumerator 方法的类型叫作可枚举类型(enumerable type 或 enumerable)。
数组是可枚举类型。
foreach 结构设计用来和可枚举类型一起使用,只要给它的遍历对象是可枚举类型,比如数组,他就会执行如下行为:
- 通过调用 GetEnumerator 方法获取对象的枚举器。
- 从枚举器中请求每一项并且把它作为迭代变量,代码可以读取该变量但不可以改变。
必须是可枚举类型
↓
foreach(Type varName in EnumerableObject)
{
...
}
IEnumerator 接口
实现了 IEnumerator 接口的枚举器包含3个函数成员:Current、MoveNext以及Reset。
- Current 是返回序列中当前位置项的属性。
- 它是只读属性
- 它返回 object 类型的引用,所以可以返回任何类型的对象。
- MoveNext 是把枚举器位置前进到集合中下一项的方法。它是返回布尔值,指示新的位置是有效位置还是已经超过了序列的尾部。
- 如果新的位置是有效的,方法返回 true。
- 如果新的位置是无效的(比如当前位置到达了尾部),方法返回 false。
- 枚举器的原始位置在序列中的第一项之前,因此 MoveNext 必须在第一次使用 Current 之前调用。
- Reset 是把位置重置为原始状态的方法。
手动 foreach 语句:
static void Main()
{
int[] arry = { 11, 22, 33, 44 };
IEnumerator ie = arry.GetEnumerator();
while (ie.MoveNext()) //移到下一项
{
int item = (int)ie.Current; //获取当前项
Console.WriteLine(item);
}
ie.Reset(); //重置为原始位置
}
IEnumerable 接口
可枚举类是指实现了 IEnumberable 接口的类。IEnumerable 接口只有一个成员——GetEnumerator 方法,它返回对象的枚举器。
使用 IEnumerable 和 IEnumerator 示例:
public class MyColors : IEnumerable
{
private string[] Colors = { "red", "yellow", "blue", "black" };
public IEnumerator GetEnumerator()
{
return new ColorEnumerator(Colors);
}
}
public class ColorEnumerator : IEnumerator
{
private string[] colors;
private int position = -1;
public ColorEnumerator(string[] theColors) //构造函数
{
colors = new string[theColors.Length];
for (int i = 0; i < theColors.Length; i++)
{
colors[i] = theColors[i];
}
}
public bool MoveNext() //实现MoveNext
{
if (position < colors.Length - 1)
{
position++;
return true;
}
else
return false;
}
public void Reset() //实现Reset
{
position = -1;
}
public object Current //实现Current
{
get
{
if (position==-1)
{
throw new InvalidOperationException();
}
if (position>=colors.Length)
{
throw new InvalidOperationException();
}
return colors[position];
}
}
}
使用这个类:
public static void Main(string[] args)
{
MyColors mc = new MyColors();
foreach (var color in mc)
{
Console.WriteLine(color);
}
}
输出结果:
red
yellow
blue
black
泛型枚举接口
实际上在大多数情况下应该使用泛型版本IEnumerable<T>
和IEnumerator<T>
。
- 这些是协变接口,所以它们的实际声明是
IEnumerable<out T>
和IEnumerator<out T>
。 IEnumerator<T>
的类实现了 Current 属性,它返回实际类型的实例,而不是 object 基类的引用。
泛型接口的枚举器是类型安全的,它返回实际类型的引用。
迭代器
除了自己创建枚举器和可枚举类,C#从2.0版本就提供更简单的方式,编译器为我们创建他们,这种结构叫作迭代器(iterator)。
迭代器块
迭代器块是有一个或多个 yield 语句的代码块。
以下都可以是迭代器块:
- 方法主体
- 访问器主体
- 运算符主体
迭代器块与其他代码块不同。其他块包含的语句被当作是命令式的。也就是说,先执行代码块的第一个语句,然后执行后面的语句,最后控制离开块。
迭代器块不是需要在同一时间执行的一串命令式命令,而是声明性的,它描述了希望编译器为我们创建的枚举器类的行为。迭代器块中的代码描述了如何枚举元素。
迭代器块中有两个特殊语句: - yield return 语句指定了序列中返回的下一项。
- yield break 语句指定在序列中没有其他项。
使用迭代器来创建枚举器
public class Iter
{
public IEnumerator<string> GetEnumerator()
{
return BlackAndWhite(); //返回枚举器
}
public IEnumerator<string> BlackAndWhite() //迭代器
{
yield return "Black";
yield return "White";
}
}
执行:
public static void Main(string[] args)
{
Iter i = new Iter();
foreach (var co in i)
{
Console.WriteLine(co);
}
}
执行结果:
Black
White
使用迭代器创建可枚举类型
public class Iter
{
public IEnumerator<string> GetEnumerator()
{
IEnumerable<string> myEnumerable = BlackAndWhite(); //获取可枚举类型
return myEnumerable.GetEnumerator(); //返回枚举器
}
public IEnumerable<string> BlackAndWhite() //迭代器
{
yield return "Black";
yield return "White";
}
}
执行:
public static void Main(string[] args)
{
Iter i = new Iter();
//使用类对象
foreach (var co in i)
{
Console.WriteLine(co);
}
//使用类枚举器方法
foreach (var co in i.BlackAndWhite())
{
Console.WriteLine(co);
}
}
执行结果:
Black
White
Black
White
迭代器的实质
迭代器需要 System.Collections.Generic 命名空间,用 using 指令引入它。
编译器生成的枚举器不支持 Reset 方法。
编译器生成的枚举器类是包含4个状态的状态机。
- Before 由此调用 MoveNext 之前的初始状态。
- Running 调用 MoveNext 后进入这个状态。在这个状态中,枚举器检测并设置下一项的位置。在遇到 yield return 、yield break 或在迭代器体结束时,退出状态。
- Suspended 状态机等待下次调用 MoveNext 的状态。
- After 没有更多项可以枚举的状态。
如果状态机在 Before 或 Suspended 状态时调用了 MoveNext 方法,就转到了 Running 状态。在 Running 状态中,他检测集合的下一项并设置位置。
如果有更多项,状态机会转入 Suspended 状态;如果没有更多项,他转入并保持在 After 状态。
19. LINQ
LINQ(发音link)代表语言集成查询(Language Integrated Query)。
LINQ是 .NET 框架的扩展,允许我们以使用 SQL 查询数据库的类似方式来查询数据集合。
使用LINQ,可以从数据库、对象集合以及 XML 文档等中查询数据。
查询的定义是带有 from 和 select 关键字的语句。
例:
int[] num = { 2, 13, 5, 16 };
IEnumerable<int> lowNums = from n in num
where n < 10
select n;
foreach (int i in lowNums)
{
Console.WriteLine(i);
}
匿名类型
创建无名类类型的特性叫作匿名类型。
- 匿名类型只能用于局部变量,不能用于类成员。
- 由于匿名类型没有名字,我们必须使用 var 关键字作为变量类型。
- 不能设置匿名类型对象的属性。编译器为匿名类型创建的属性是只读的。
例:
//1.赋值形式:成员初始化语句
var student = new { Name = "Qimeile", Age = 12, Major = "History" };
//2.投影初始化语句形式
class Other
{
public static string Name = "Qimeile";
}
string Major = "History";
//Age是赋值形式,Other.Name是成员访问,Major是标识符
var student2 = new { Other.Name, Age = 19, Major };
方法语法和查询语法
LINQ 查询时可以有两种形式的语法:查询语法和方法语法。
查询语法是声明式的,查询描述的是你想返回的东西,但并没有指明如何执行这个查询;
方法语法是命令式的,它指明了查询方法调用的顺序。
int[] numbers = {2,4,5,28,13,30,1,64,33};
//查询语法
var numsQuery = from n in numbers
where n < 20
select n;
//方法语法
var numsMethod = numbers.Where(N => N < 20 );
//两种形式的组合
int numsCount = (from n in numbers
where n < 20
select n).Count();
查询变量
LINQ 查询可以返回两种类型的结果:
- 可枚举的一组数据,它是满足查询参数的项列表
- 标量,单一值,他是满足查询条件的结果的某种摘要形式。
例:
List<int> numbers = new List<int>() { 2, 4, 5, 28, 13, 30, 1, 64, 33 };
//查询语法 返回枚举器
IEnumerable<int> numsQuery = from n in numbers
where n < 20
select n;
//返回标量
int numsCount = (from n in numbers
where n < 20
select n).Count();
foreach (var i in numsQuery)
{
Console.Write(i+" ");
}
Console.WriteLine("满足条件的数量为 "+numsCount);
Console.WriteLine("=====加入新的满足条件数值=====");
numbers.Add(3);
foreach (var i in numsQuery)
{
Console.Write(i+" ");
}
Console.WriteLine("满足条件的数量为 "+numsCount);
执行结果:
2 4 5 13 1 满足条件的数量为 5
=====加入新的满足条件数值=====
2 4 5 13 1 3 满足条件的数量为 5
通过例子中的结果可以发现:
- numsQuery 查询变量不会包含查询的结果,编译器会创建能够执行这个查询的代码。
- 查询变量 numsCount 包含的是真实的整数值,他只能通过真实运行查询后获得。
- 查询执行时间的差异可以总结如下:
- 如果查询表达式返回枚举,则查询一直到处理枚举时才会执行。
- 如果枚举被处理多次,查询就会执行多次。
- 如果在遍历之后、查询执行之前数据有改动,则查询会使用新的数据。
- 如果查询表达式返回标量,查询立即执行,并且把结果保存在查询变量中。
查询表达式的结构
子句必须按照一定的顺序出现。
from
子句和 select ... group
子句这两部分是必需的,其他子句是可选的。
LINQ中 select 子句在表达式最后。这与 SQL 的 SELECT 语句在查询的开始处不一样。C# 这么做的原因之一是让 Visual Studio 智能感应能在我们输入代码时提供更多选项。
可以有任意多的 form ... let ... where
子句。
from 子句
from 子句指定了要作为数据源使用的数据集合。他还引入了迭代变量。迭代变量逐个表示数据源的每一个元素。
语法:from Type Item in Items
join 子句
联结的重要事项:
- 使用联结来结合两个或更多集合中的数据
- 联结操作接受两个集合,然后创建一个临时的对象集合,其中每一个对象包含两个原始集合对象中的所有字段。
标准查询运算符
标准查询运算符能让我们查询任何.NET数组或集合。
重要特性:
- 标准查询运算符使用方法语法。
- 一些运算符返回 Ienumerable 对象(或其他序列),而其他运算符返回标量。标量立即执行并返回一个值,而不是可枚举类型对象。ToArray()、ToList()等 ToCollection 运算符也会立即执行。
- 被查询的集合对象叫作序列,他必须实现 IEnumerable<T> 接口,其中T是类型。
LINQ to XML
LINQ 为XML增加了一些特性:
- 可以使用单一语句自顶向下创建 XML 树。
- 可以在不适用包含树的 XML 文档的情况下在内存中创建并操作 XML。
- 可以在不适用 Text 子节点的情况下创建和操作字符串节点。
- 最大改进是,在搜索一个 XML 树时,不再需要遍历它,只需要查询树并让它返回结果。
XML 基础
- XML标签区分大小写
示例:
<persons>
<person>
<name>张三</name>
<length> 175cm</length>
</person>
<person>
<name>李四 </name>
<length>200cm</length>
</person>
</persons>
XML 类
LINQ to XML 可以有两种方式用于 XML。
- 简化的 XML 操作 API
- LINQ 查询工具
20. 异步编程
什么是异步
启动程序时,系统会在内存中创建一个新的进程。进程是构成运行程序的资源的集合。
在进程内部,系统创建了一个称为线程的内核(kernel)对象。
关于线程:
- 默认情况下,一个进程只包含一个线程,从程序的开始一直到执行到结束。
- 线程可以派生其他线程,一个进程可能包含不同状态的多个线程。
- 如果一个进程有多个线程,它们将共享进程的资源。
- 系统为处理器执行所调度的单元是线程,不是进程。
async/await
异步的方法在完成其所有工作之前就返回到调用方法。
- 调用方法(calling method):该方法调用异步方法,然后再异步方法执行其任务的时候继续执行。
- 异步(async)方法:该方法异步执行其工作,然后立即返回到调用方法。
- await 表达式:用于异步方法内部,指明需要异步执行的任务。一个异步方法可以包含任意多个 await 表达式,如果一个都不包含的话编译器会发出警告。
异步方法
- 异步方法的头方法中必须包含 async 关键字,且必须位于返回类型之前。
- async 关键字是一个上下文关键字,除了作为方法修饰符之外,还可以用作标识符。
- 异步方法的返回类型:
- Task:不需要返回值,但需要检查异步方法的状态
- Task<T>:调用方法通过读取 Task 的Result 属性来获取这个 T 类型的值。
- ValueTask<T>:这是一个值类型对象,他与 Task<T> 类似,但用于任务结果可能已经可用的情况。因为它是值类型,所以它可以放在栈上,而无须像 Task<T> 对象那样再堆上分配空间。某种情况下可以提高性能。
- void:调用方法仅想执行异步,而不需要与他做任何进一步的交互时。
- 任何具有可访问的 GetAwaiter 方法的类型。
await 表达式
await 表达式指定了一个异步执行的任务,由 await 关键字和一个空闲对象(称为任务,可能是 Task 类型,也可能不是)组成。默认情况下,这个任务在当前线程上异步执行。
一个空闲对象即是 awaitable 类型的实例。awaitable 类型包含了 GetAwaiter 方法,该方法没有参数,返回一个 awaiter 类型的对象。
awaiter 类型包含如下成员:
bool IsCompleted { get; }
void OnCompleted(Action)
同时还包含以下成员之一:void GetResult();
T GetResult();
T 为任意类型
实际上,不需要构建自己的 awaitable,而是使用 Task 类或 ValueTask 类,它们是 awaitable 类型。通过 Task.Run 方法来创建 Task,其签名如下。其中 Func<TReturn> 是一个预定义委托,不包含任何参数,返回类型为 TReturn。
取消一个异步操作
System.Threading.Tasks 命名空间中有两个类被设计为取消异步操作,分别为 CancellationToken 和 CancellationTokenSource。
- CancellationToken 包含一个任务是否应被取消的信息 IsCancellationRequested。
- 拥有 CancellationToken 对象的任务需要定期检查其令牌状态,如果 IsCancellationRequested 属性为 true,任务需停止其操作并返回。
- CancellationToken 不可逆,且只能使用一次。即,一旦 IsCancellationRequested 被设置为 true,就不能更改。
- CancellationTokenSource 对象创建可分配给不同任务的 CancellationToken 对象,任何持有 CancellationTokenSource 的对象都可以调用其 Cancel 方法,这将使 IsCancellationRequested 被设置为 true。
异常处理和 await 表达式
await 表达式可以像其他表达式一样放在 try 语句内,try … catch … finally 结构将按你期望的那样工作。
C#6.0 后也可以在 catch 和 finally 块中使用 await 表达式。在异常不需要终止应用程序时,可以使用 await 来记录日志或运行其他时间较长的任务。如果新的异步任务也产生了一场,则任何原有的异常信息都将丢失。
在调用方法中同步地等待任务
使用 Wait 方法可以等待 Task 对象完成。
Wait 方法用于单一 Task 对象。
可以使用 Task 类中的静态方法等待一组 Task 对象完成。
- WaitAll :等待一组 Task 对象全部完成。
- WaitAny:等待一组中至少一个 Task 对象完成。
WaitAll 和 WaitAny 分别包含 4 个重载,除了完成任务之外,还允许以不同的方式继续执行,如设置超时时间或使用 CancellationToken 来强制执行处理的后续部分。
在异步方法中异步地等待任务
使用 Task.WhenAll 和 Task.WhenAny 异步等待多个 Task。这两个方法称为组合子(combinator)。
Task.Delay 方法
Task.Delay 方法创建一个 Task 对象,该对象将暂停其在线程中的处理,并在一定时间之后完成。
- Thread.Sleep:阻塞线程。
- Task.Delay:不阻塞线程,线程可以继续处理其他工作。
例:
public class Simple
{
private Stopwatch sw = new Stopwatch();
public void DoRun()
{
Console.WriteLine("Caller: Before call");
ShowDelayAsync();
Console.WriteLine("Caller: After call");
}
private async void ShowDelayAsync()
{
sw.Start();
Console.WriteLine("Before Delay:"+sw.ElapsedMilliseconds);
await Task.Delay(1000);
Console.WriteLine("After Delay:"+sw.ElapsedMilliseconds);
}
}
public class Program
{
public static Main(string[] args)
{
Simple ds = new Simple();
ds.DoRun();
Console.ReadLine();
}
}
执行结果:
Caller: Before call
Before Delay:0
Caller: After call
After Delay:1003
Task.Yield
Task.Yield 方法创建一个立即返回的 awaitable。 等待一个 Yield 可以让异步方法在执行后续部分的同时返回到调用方法。可以将其理解成离开当前的消息队列,回到队列末尾,让处理器有时间处理其他任务。
使用异步 Lambda 表达式
除了异步方法,还可以使用异步匿名方法和异步 Lambda 表达式。
例:
startWorkButton.Click += async (sender, e) => { ... };
BackgroundWorker 类
可以使用 BackgroundWorker 类创建后台线程。
并行循环
任务并行库(Task Parallel Library)
这里只介绍两个:
- Parallel.For 循环
- Parallel.ForEach 循环
其他异步编程
当委托调用时,他的调用列表只有一个方法,它就可以异步执行这个方法。
- BeginInvoke:
- 参数列表:引用方法需要的参数,callback 参数,state 参数
- BeginInvoke 从线程池中获取一个线程并且引用方法在新的线程中开始运行,返回给调用线程一个实现 IAsyncResult 的接口的对象的引用。这个接口引用包含了在线程池中运行的异步方法的当前状态。然后原始线程可以继续执行。
- EndInvoke:
- 参数列表:IAsyncResult 对象的引用
- EndInvoke 方法用来获取由异步方法调用返回的值,并且释放线程使用的资源。
- EndInvoke 提供了异步方法调用的所有输出,包括 ref 和 out 参数,如果委托的引用方法有 ref 或 out 参数,则它们必须包含在 EndInvoke 的参数列表中,并且在 IAsyncResult 对象引用之前。如:
long result = del.EndInvoke(out someInt, iar);
。
异步方法调用的三种标准模式
- 在等待直到完成模式中,在发起了异步方法以及做了一些其他处理之后,原始线程就中断并且等异步方法完成之后再继续。
- 在轮询模式中,原始线程定期检查发起的线程是否完成,如果没有则可以继续做一些其他的事情。
- 在回调模式中,原始线程一直执行,无须等待或检查发起的线程是否完成。
21. 命名空间和程序集
22. 异常
23. 预处理指令
24. 反射和特性
特性一定使用了反射,反射不一定用到特性。
元数据和反射
元数据:有关程序及其类型的数据。
反射:运行中的程序查看本身的元数据或其他程序的元数据的行为。
Type 类
type 是一个抽象类,CLR 创建从 Type 派生的类的实例,Type 包含了类型信息。
- 不管创建的类型有多少个实例,只有一个 Type 对象会关联到所有这些实例。
- 每一个类型,CLR 都会创建一个包含这个类型信息的 Type 类型的对象。
System.Type 类的部分成员
成员 | 成员类型 | 描述 |
---|---|---|
Name | 属性 | 返回类型的名字 |
Namespace | 属性 | 返回包含类型声明的命名空间 |
Assembly | 属性 | 返回声明类型的程序集。如果是泛型,返回定义这个类型的程序集。 |
GetFields | 方法 | 返回类型的字段列表 |
GetProperties | 方法 | 返回类型的属性列表 |
GetMethdos | 方法 | 返回类型的方法列表 |
获取 Type 对象
- GetType 方法:object 类型包含 GetType 方法,它返回实例的 Type 对象的引用。
Type t = myInstance.GetType();
- typeof 运算符:提供类型名作为参数,返回 Type 对象的引用。
Type t = typeof( DerivedClass );
什么是特性
特性(attribute)是一种允许我们向程序的程序集添加元数据的语言结构。它是用于保存程序结构信息的特殊类型的类。
- 应用了特性的程序结构叫作目标(target)。
- 设计用来获取和使用元数据的程序叫作特性的消费者(consumer)
按照习惯,特性名使用 Pascal 命名法并且以 Attribute 后缀结尾。
应用特性
特性的目的是告诉编译器把程序结构的某组元素嵌入程序集。可以通过把特性应用到结构来实现。
- 通过在结构前放置特性片段来应用特性。
- 特性片段有方括号包围特性名和(有时候)参数列表构成。
例:
[Serializable] //特性
public class MyClass{ ... }
[MyAttribute("Simple class","Version 3.5")] //带参数的特性
publc class MyOtherClass{ ... }
预定义的保留特性
Obsolete 特性
这个特性标记了不应被使用的程序实体。
- 参数 message,是一个字符串,描述项目为什么过时的原因以及该替代使用什么。
- 参数 iserror,是一个布尔值。如果该值为 true,编译器应把该项目的使用当作一个错误。默认值是 false(编译器生成一个警告)。
Conditional 特性
允许我们包括或排斥特定方法的所有调用。
使用规则:
- 该方法是类或结构体的方法,必须是 void 类型,不能声明为 override,但可以标记为 virtual。
- 该方法不能是接口方法的实现。