教程:
.NET | 构建。测试。部署。 (microsoft.com)
C# 文档 - 入门、教程、参考。 | Microsoft Learn
IDE:
Visual Studio: 面向软件开发人员和 Teams 的 IDE 和代码编辑器 (microsoft.com)
Rider:JetBrains 出品的跨平台 .NET IDE
最新版本:
.NET8下载
下载 .NET(Linux、macOS 和 Windows) (microsoft.com)
.NET 8 的新增功能
.NET 8 的新增功能 | Microsoft Learn
C# 应用领域
- 人工智能
- 物联网
- 桌面开发
- 网页开发
- 游戏开发
- 云应用
- 移动应用
目前唯一一种能同时涵盖这些领域的语言
C# 误区
C#开源跨平台
CLI 和 ClR
CLI:Common LanguageInfrastructure 公共语言基础框架
- 用来处理代码编译过程
- 类似Java代码编译为字节码的过程
CLR:Common LanguageRuntime公共语言运行时(服务环境)
- 代码运行环境
- 相当于Java的JVM虚拟机
CLI + CLR = .NET Core
Hello World
1、Visual Studio创建控制台应用 MyApp
从 .NET 6 开始,新 C# 控制台应用的项目模板在 Program.cs 文件中生成以下代码:
c#
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
新的输出使用最新的 C# 功能,这些功能简化了需要为程序编写的代码。 对于 .NET 5 及更早版本,控制台应用模板生成以下代码:
c#
using System;
namespace MyApp // Note: actual namespace depends on the project name.
{
internal class Program
{
static void Main(string[] args)
{
/* 我的第一个 C# 程序*/
Console.WriteLine("Hello World!");
Console.ReadKey();
}
}
}
让我们看一下上面程序的各个部分:
-
程序的第一行 using System; - using 关键字用于在程序中包含 System 命名空间。 一个程序一般有多个 using 语句。
-
下一行是 namespace 声明。一个 namespace 里包含了一系列的类。MyApp 命名空间包含了类 Program。
-
下一行是 class 声明。类 Program 包含了程序使用的数据和方法声明。类一般包含多个方法。方法定义了类的行为。在这里,Program 类只有一个 Main 方法。
-
下一行定义了 Main 方法,是所有 C# 程序的 入口点 。Main 方法说明当执行时 类将做什么动作。
-
下一行 /... / 将会被编译器忽略,且它会在程序中添加额外的 注释。
-
Main 方法通过语句
Console.WriteLine("Hello World");
指定了它的行为。
WriteLine 是一个定义在 System 命名空间中的 Console 类的一个方法。该语句会在屏幕上显示消息 "Hello World"。
-
最后一行 Console.ReadKey(); 是针对 VS.NET 用户的。这使得程序会等待一个按键的动作,防止程序从 Visual Studio .NET 启动时屏幕会快速运行并关闭。
以下几点值得注意:
- C# 是大小写敏感的。
- 所有的语句和表达式必须以分号(;)结尾。
- 程序的执行从 Main 方法开始。
- 与 Java 不同的是,文件名可以不同于类的名称。
控制台 System.Console
介绍
System.Console 类 - .NET | Microsoft Learn
方法
Console 类 (System) | Microsoft Learn
c#
namespace ConsoleApp1 // Note: actual namespace depends on the project name.
{
public static class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Hello World");
Console.WriteLine("Hello World");
Console.WriteLine("Welcome");
Console.WriteLine(" To");
Console.WriteLine(" C#");
ConsoleKeyInfo a = Console.ReadKey();
Console.WriteLine(a.Key);
Console.Clear();
Console.Read();
}
}
}
C# 基本语法
c#
using System;
namespace RectangleApplication
{
class Rectangle
{
// 成员变量
double length;
double width;
public void Acceptdetails()
{
length = 4.5;
width = 3.5;
}
public double GetArea()
{
return length * width;
}
public void Display()
{
Console.WriteLine("Length: {0}", length);
Console.WriteLine("Width: {0}", width);
Console.WriteLine("Area: {0}", GetArea());
}
}
class ExecuteRectangle
{
static void Main(string[] args)
{
Rectangle r = new Rectangle();
r.Acceptdetails();
r.Display();
Console.ReadLine();
}
}
}
using 关键字
在任何 C# 程序中的第一条语句都是:
using System;
using 关键字用于在程序中包含命名空间。一个程序可以包含多个 using 语句。
class 关键字
class 关键字用于声明一个类。
C# 中的注释
注释是用于解释代码。编译器会忽略注释的条目。在 C# 程序中,多行注释以 /* 开始,并以字符 */ 终止,如下所示:
/* 这个程序演示
C# 的注释
使用 */
单行注释是用 // 符号表示。例如:
// 这一行是注释
成员变量
变量是类的属性或数据成员,用于存储数据。在上面的程序中,Rectangle 类有两个成员变量,名为 length 和 width。
成员函数
函数是一系列执行指定任务的语句。类的成员函数是在类内声明的。我们举例的类 Rectangle 包含了三个成员函数: AcceptDetails 、GetArea 和 Display。
实例化一个类
在上面的程序中,类 ExecuteRectangle 是一个包含 Main() 方法和实例化 Rectangle 类的类。
标识符
标识符是用来识别类、变量、函数或任何其它用户定义的项目。在 C# 中,类的命名必须遵循如下基本规则:
- 标识符必须以字母、下划线或 @ 开头,后面可以跟一系列的字母、数字( 0 - 9 )、下划线( _ )、@。
- 标识符中的第一个字符不能是数字。
- 标识符必须不包含任何嵌入的空格或符号,比如 ? - +! # % ^ & * ( ) [ ] { } . ; : " ' / \。
- 标识符不能是 C# 关键字。除非它们有一个 @ 前缀。 例如,@if 是有效的标识符,但 if 不是,因为 if 是关键字。
- 标识符必须区分大小写。大写字母和小写字母被认为是不同的字母。
- 不能与C#的类库名称相同。
C# 关键字
关键字是 C# 编译器预定义的保留字。这些关键字不能用作标识符,但是,如果您想使用这些关键字作为标识符,可以在关键字前面加上 @ 字符作为前缀。
在 C# 中,有些关键字在代码的上下文中有特殊的意义,如 get 和 set,这些被称为上下文关键字(contextual keywords)。
下表列出了 C# 中的保留关键字(Reserved Keywords)和上下文关键字(Contextual Keywords):
保留关键字 | ||||||
---|---|---|---|---|---|---|
abstract | as | base | bool | break | byte | case |
catch | char | checked | class | const | continue | decimal |
default | delegate | do | double | else | enum | event |
explicit | extern | false | finally | fixed | float | for |
foreach | goto | if | implicit | in | in (generic modifier) | int |
interface | internal | is | lock | long | namespace | new |
null | object | operator | out | out (generic modifier) | override | params |
private | protected | public | readonly | ref | return | sbyte |
sealed | short | sizeof | stackalloc | static | string | struct |
switch | this | throw | true | try | typeof | uint |
ulong | unchecked | unsafe | ushort | using | virtual | void |
volatile | while | |||||
上下文关键字 | ||||||
add | alias | ascending | descending | dynamic | from | get |
global | group | into | join | let | orderby | partial (type) |
partial (method) | remove | select | set |
变量(Variable)与数据类型(Data Type)
在 C# 中,变量分为以下几种类型:
- 值类型(Value types)
- 引用类型(Reference types)
- 指针类型(Pointer types)
值类型(Value types)(基本类型)
值类型变量可以直接分配给一个值。它们是从类 System.ValueType 中派生的。
值类型直接包含数据。比如 int、char、float ,它们分别存储数字、字符、浮点数。当您声明一个 int 类型时,系统分配内存来存储值。
下表列出了 C# 2010 中可用的值类型:
类型 | 描述 | 范围 | 默认值 |
---|---|---|---|
bool | 布尔值 | True 或 False | False |
byte | 8 位无符号整数 | 0 到 255 | 0 |
char | 16 位 Unicode 字符 | U +0000 到 U +ffff | '\0' |
decimal | 128 位精确的十进制值,28-29 有效位数 | (-7.9 x 1028 到 7.9 x 1028) / 100 到 28 | 0.0M |
double | 64 位双精度浮点型 | (+/-)5.0 x 10-324 到 (+/-)1.7 x 10308 | 0.0D |
float | 32 位单精度浮点型 | -3.4 x 1038 到 + 3.4 x 1038 | 0.0F |
int | 32 位有符号整数类型 | -2,147,483,648 到 2,147,483,647 | 0 |
long | 64 位有符号整数类型 | -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 | 0L |
sbyte | 8 位有符号整数类型 | -128 到 127 | 0 |
short | 16 位有符号整数类型 | -32,768 到 32,767 | 0 |
uint | 32 位无符号整数类型 | 0 到 4,294,967,295 | 0 |
ulong | 64 位无符号整数类型 | 0 到 18,446,744,073,709,551,615 | 0 |
ushort | 16 位无符号整数类型 | 0 到 65,535 | 0 |
-
u = unsigned,无符号的意思
-
s = signed,有符号的意思
-
char无法表示中文:中文标准是GBK、不属于UTF-16
-
内建类型(引用类型),但不属于基本类型
- 中文得用string类型
- string可以通过把多个Unicode字符拼接起来
- 显示中文、甚至是emoji
- object对象类型
- dynamic动态类型
- 中文得用string类型
如需得到一个类型或一个变量在特定平台上的准确尺寸,可以使用 sizeof 方法。表达式 sizeof(type) 产生以字节为单位存储对象或类型的存储尺寸。下面举例获取任何机器上 int 类型的存储尺寸:
实例
c#
using System;
namespace DataTypeApplication
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Size of int: {0}", sizeof(int));
Console.ReadLine();
}
}
}
当上面的代码被编译和执行时,它会产生下列结果:
Size of int: 4
引用类型(Reference types)
引用类型不包含存储在变量中的实际数据,但它们包含对变量的引用。
换句话说,它们指的是一个内存位置。使用多个变量时,引用类型可以指向一个内存位置。如果内存位置的数据是由一个变量改变的,其他变量会自动反映这种值的变化。内置的 引用类型有:object 、dynamic 和 string。
对象(Object)类型
对象(Object)类型 是 C# 通用类型系统(Common Type System - CTS)中所有数据类型的终极基类。Object 是 System.Object 类的别名。所以对象(Object)类型可以被分配任何其他类型(值类型、引用类型、预定义类型或用户自定义类型)的值。但是,在分配值之前,需要先进行类型转换。
当一个值类型转换为对象类型时,则被称为 装箱 ;另一方面,当一个对象类型转换为值类型时,则被称为 拆箱。
object obj;
obj = 100; // 这是装箱
动态(Dynamic)类型
背景知识
- 语言分类:静态类型语言和动态类型语言
- 静态类型语言会在编译的时候检查数据类型 C++、Java、C#
- 而动态类型则相反,直到程序运行起来,数据类型才会被确定下来 JavaScript、python
强类型 VS 弱类型
- 从类型能否转换的角度来说
var answer = 22 * "3";
- C# 不合法(强语言,类型安全)JavaScript 合法(弱语言,类型不安全)
C# 属于强语言,同时略偏静态类型
- 强类型语言,因为它不支持类型的自动转化
- 略偏静态类型
??
- 主要是静态语言,但是因为存在
dynamic
这个关键词,所以它也具有动态类型的特点
您可以存储任何类型的值在动态数据类型变量中。这些变量的类型检查是在运行时发生的。
声明动态类型的语法:
dynamic <variable_name> = value;
例如:
dynamic d = 20;
动态类型与对象类型相似,但是对象类型变量的类型检查是在编译时发生的,而动态类型变量的类型检查是在运行时发生的。
字符串(String)类型
字符串(String)类型 允许您给变量分配任何字符串值。字符串(String)类型是 System.String 类的别名。它是从对象(Object)类型派生的。字符串(String)类型的值可以通过两种形式进行分配:引号和 @引号。
例如:
String str = "runoob.com";
一个 @引号字符串:
@"runoob.com";
C# string 字符串的前面可以加 @(称作"逐字字符串")将转义字符(\)当作普通字符对待,比如:
string str = @"C:\Windows";
等价于:
string str = "C:\\Windows";
@ 字符串中可以任意换行,换行符及缩进空格都计算在字符串长度之内。
string str = @"<script type=""text/javascript"">
<!--
-->
</script>";
用户自定义引用类型有:class、interface 或 delegate。我们将在以后的章节中讨论这些类型。
指针类型(Pointer types)
指针类型变量存储另一种类型的内存地址。C# 中的指针与 C 或 C++ 中的指针有相同的功能。
声明指针类型的语法:
type* identifier;
例如:
char* cptr;
int* iptr;
我们将在章节"不安全的代码"中讨论指针类型。
字符串方法与操作
内置引用类型 - C# 引用 - C# | Microsoft Learn
string
类型表示零个或多个 Unicode 字符的序列。 string
是 System.String 在 .NET 中的别名。
尽管 string
为引用类型,但是定义相等运算符 ==
和 !=
是为了比较 string
对象(而不是引用)的值。 基于值的相等性使得对字符串相等性的测试更为直观。 例如:
c#
string a = "hello";
string b = "h";
// Append to contents of 'b'
b += "ello";
Console.WriteLine(a == b);
Console.WriteLine(object.ReferenceEquals(a, b));
前面的示例显示"True",然后显示"False",因为字符串的内容是相等的,但 a
和 b
并不指代同一字符串实例。
+ 运算符连接字符串:
c#
string a = "good " + "morning";
前面的代码会创建一个包含"good morning"的字符串对象。
字符串是不可变的,即:字符串对象在创建后,其内容不可更改。 例如,编写此代码时,编译器实际上会创建一个新的字符串对象来保存新的字符序列,且该新对象将赋给 b
。 已为 b
分配的内存(当它包含字符串"h"时)可用于垃圾回收。
c#
string b = "h";
b += "ello";
[]
运算符可用于只读访问字符串的个别字符。 有效索引于 0
开始,且必须小于字符串的长度:
c#
string str = "test";
char x = str[2]; // x = 's';
同样,[]
运算符也可用于循环访问字符串中的每个字符:
c#
string str = "test";
for (int i = 0; i < str.Length; i++)
{
Console.Write(str[i] + " ");
}
// Output: t e s t
字符串文本
字符串字面量属于 string
类型且可以三种形式编写(原始、带引号和逐字)。
原始字符串字面量从 C# 11 开始可用。 字符串字面量可以包含任意文本,而无需转义序列。 字符串字面量可以包括空格和新行、嵌入引号以及其他特殊字符。 原始字符串字面量用至少三个双引号 (""") 括起来:
c#
"""
This is a multi-line
string literal with the second line indented.
"""
甚至可以包含三个(或更多)双引号字符序列。 如果文本需要嵌入的引号序列,请根据需要使用更多引号开始和结束原始字符串字面量:
c#
"""""
This raw string literal has four """", count them: """" four!
embedded quote characters in a sequence. That's why it starts and ends
with five double quotes.
You could extend this example with as many embedded quotes as needed for your text.
"""""
原始字符串字面量的起始和结束引号序列通常位于与嵌入文本不同的行上。 多行原始字符串字面量支持自带引号的字符串:
c#
var message = """
"This is a very important message."
""";
Console.WriteLine(message);
// output: "This is a very important message."
当起始引号和结束引号在不同的行上时,则最终内容中不包括起始引号之后和结束引号之前的换行符。 右引号序列指示字符串字面量的最左侧列。 可以缩进原始字符串字面量以匹配整体代码格式:
c#
var message = """
"This is a very important message."
""";
Console.WriteLine(message);
// output: "This is a very important message."
// The leftmost whitespace is not part of the raw string literal
保留结束引号序列右侧的列。 此行为将为 JSON、YAML 或 XML 等数据格式启用原始字符串,如以下示例所示:
c#
var json= """
{
"prop": 0
}
""";
如果任何文本行扩展到右引号序列的左侧,编译器将发出错误。 左引号和右引号序列可以位于同一行上,前提是字符串字面量既不能以引号字符开头,也不能以引号字符结尾:
c#
var shortText = """He said "hello!" this morning.""";
可以将原始字符串字面量与字符串内插相结合,以在输出字符串中包含引号字符和大括号。
带引号字符串括在双引号 (") 内。
c#
"good morning" // a string literal
字符串文本可包含任何字符文本。 包括转义序列。 下面的示例使用转义序列 \\
表示反斜杠,使用 \u0066
表示字母 f,以及使用 \n
表示换行符。
c#
string a = "\\\u0066\n F";
Console.WriteLine(a);
// Output:
// \f
// F
转义码
\udddd
(其中dddd
是一个四位数字)表示 Unicode 字符 U+dddd
。 另外,还可识别八位 Unicode 转义码:\Udddddddd
。
逐字字符串文本以 @
开头,并且也括在双引号内。 例如:
c#
@"good morning" // a string literal
逐字字符串的优点是不处理转义序列,这样就可轻松编写。 例如,以下文本与完全限定的 Windows 文件名匹配:
c#
@"c:\Docs\Source\a.txt" // rather than "c:\\Docs\\Source\\a.txt"
若要在用 @ 引起来的字符串中包含双引号,双倍添加即可:
c#
@"""Ahoy!"" cried the captain." // "Ahoy!" cried the captain.
UTF-8 字符串字面量
.NET 中的字符串是使用 UTF-16 编码存储的。 UTF-8 是 Web 协议和其他重要库的标准。 从 C# 11 开始,可以将 u8
后缀添加到字符串字面量以指定 UTF-8 编码。 UTF-8 字面量存储为 ReadOnlySpan<byte>
对象。 UTF-8 字符串字面量的自然类型是 ReadOnlySpan<byte>
。 使用 UTF-8 字符串字面量创建的声明比声明等效的 System.ReadOnlySpan 更清晰,如以下代码所示:
c#
ReadOnlySpan<byte> AuthWithTrailingSpace = new byte[] { 0x41, 0x55, 0x54, 0x48, 0x20 };
ReadOnlySpan<byte> AuthStringLiteral = "AUTH "u8;
要将 UTF-8 字符串字面量存储为数组,需使用 ReadOnlySpan.ToArray() 将包含字面量的字节复制到可变数组:
c#
byte[] AuthStringLiteral = "AUTH "u8.ToArray();
UTF-8 字符串字面量不是编译时常量;而是运行时常量。 因此,不能将其用作可选参数的默认值。 UTF-8 字符串字面量不能与字符串内插结合使用。 不能对同一字符串表达式使用 $
令牌和 u8
后缀。
使用 $
的字符串内插
$ - 字符串内插 - 格式字符串输出 - C# | Microsoft Learn
$
字符将字符串字面量标识为内插字符串。 内插字符串是可能包含内插表达式的字符串文本 。 将内插字符串解析为结果字符串时,带有内插表达式的项会替换为表达式结果的字符串表示形式。
字符串内插为格式化字符串提供了一种可读性和便捷性更高的方式。 它比字符串复合格式设置更容易阅读。 下面的示例使用了这两种功能生成同样的输出结果:
c#
var name = "Mark";
var date = DateTime.Now;
// Composite formatting:
Console.WriteLine("Hello, {0}! Today is {1}, it's {2:HH:mm} now.", name, date.DayOfWeek, date);
// String interpolation:
Console.WriteLine($"Hello, {name}! Today is {date.DayOfWeek}, it's {date:HH:mm} now.");
// Both calls produce the same output that is similar to:
// Hello, Mark! Today is Wednesday, it's 19:40 now.
从 C# 10 开始,可以使用内插字符串来初始化常量字符串。 仅当内插字符串中的所有内插表达式也是常量字符串时,才可以执行此操作。
内插字符串的结构
若要将字符串标识为内插字符串,可在该字符串前面加上 $
符号。 字符串字面量开头的 $
和 "
之间不能有任何空格。
具备内插表达式的项的结构如下所示:
c#
{<interpolationExpression>[,<alignment>][:<formatString>]}
括号中的元素是可选的。 下表说明了每个元素:
元素 | 描述 |
---|---|
interpolationExpression |
生成需要设置格式的结果的表达式。null 的字符串表示形式为 String.Empty。 |
alignment |
常数表达式,它的值定义表达式结果的字符串表示形式中的最小字符数。 如果值为正,则字符串表示形式为右对齐;如果值为负,则为左对齐。 有关详细信息,请参阅复合格式设置一文的对齐组件部分。 |
formatString |
受表达式结果类型支持的格式字符串。 有关详细信息,请参阅复合格式设置一文的格式字符串组件部分。 |
以下示例使用上述可选的格式设置组件:
c#
Console.WriteLine($"|{"Left",-7}|{"Right",7}|");
const int FieldWidthRightAligned = 20;
Console.WriteLine($"{Math.PI,FieldWidthRightAligned} - default formatting of the pi number");
Console.WriteLine($"{Math.PI,FieldWidthRightAligned:F3} - display only three decimal digits of the pi number");
// Output is:
// |Left | Right|
// 3.14159265358979 - default formatting of the pi number
// 3.142 - display only three decimal digits of the pi number
从 C# 11 开始,可以在内插表达式中使用换行符,以使表达式的代码更具可读性。 下面的示例展示了换行符如何提高涉及模式匹配的表达式的可读性:
c#
string message = $"The usage policy for {safetyScore} is {
safetyScore switch
{
> 90 => "Unlimited usage",
> 80 => "General usage, with daily safety check",
> 70 => "Issues must be addressed within 1 week",
> 50 => "Issues must be addressed within 1 day",
_ => "Issues must be addressed before continued use",
}
}";
内插原始字符串字面量
从 C# 11 开始,可以使用内插原始字符串字面量,如以下示例所示:
c#
int X = 2;
int Y = 3;
var pointMessage = $"""The point "{X}, {Y}" is {Math.Sqrt(X * X + Y * Y):F3} from the origin""";
Console.WriteLine(pointMessage);
// Output is:
// The point "2, 3" is 3.606 from the origin
要在结果字符串中嵌入 {
和 }
字符,请让内插原始字符串字面量以多个 $
字符开头。 执行此操作时,任何长度短于 $
字符数的 {
或 }
字符序列都会嵌入到结果字符串中。 若要将任何内插表达式包含在该字符串中,需要使用与 $
字符数相同的大括号数,如以下示例所示:
c#
int X = 2;
int Y = 3;
var pointMessage = $$"""{The point {{{X}}, {{Y}}} is {{Math.Sqrt(X * X + Y * Y):F3}} from the origin}""";
Console.WriteLine(pointMessage);
// Output is:
// {The point {2, 3} is 3.606 from the origin}
在前面的示例中,内插原始字符串字面量以两个 $
字符开头。 这就是为什么你需要将每个内插表达式放在双大括号 {``{
和 }}
之间。 单个大括号嵌入到结果字符串中。 如果需要将重复的 {
或 }
字符嵌入结果字符串中,请使用相应增加的 $
字符数来指定内插原始字符串字面量。
特殊字符
要在内插字符串生成的文本中包含大括号 "{" 或 "}",请使用两个大括号,即 "{{" 或 "}}"。 有关详细信息,请参阅复合格式设置一文的转义括号部分。
因为冒号(":")在内插表达式项中具有特殊含义,为了在内插表达式中使用条件运算符,请将表达式放在括号内。
以下示例演示了如何在结果字符串中包括大括号。 它还演示了如何使用条件运算符:
c#
string name = "Horace";
int age = 34;
Console.WriteLine($"He asked, \"Is your name {name}?\", but didn't wait for a reply :-{{");
Console.WriteLine($"{name} is {age} year{(age == 1 ? "" : "s")} old.");
// Output is:
// He asked, "Is your name Horace?", but didn't wait for a reply :-{
// Horace is 34 years old.
内插逐字字符串以 $
和 @
字符开头。 可以按任意顺序使用 $
和 @
:$@"..."
和 @$"..."
均为有效的内插逐字字符串。 有关逐字字符串的详细信息,请参阅字符串和逐字标识符文章。
特定于区域性的格式设置
默认情况下,内插字符串将 CultureInfo.CurrentCulture 属性定义的当前区域性用于所有格式设置操作。
要将内插字符串解析为特定于区域性的结果字符串,请使用 String.Create(IFormatProvider, DefaultInterpolatedStringHandler) 方法,该方法从 .NET 6 开始可用。 下面的示例演示如何执行此操作:
c#
double speedOfLight = 299792.458;
System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo("nl-NL");
string messageInCurrentCulture = $"The speed of light is {speedOfLight:N3} km/s.";
var specificCulture = System.Globalization.CultureInfo.GetCultureInfo("en-IN");
string messageInSpecificCulture = string.Create(
specificCulture, $"The speed of light is {speedOfLight:N3} km/s.");
string messageInInvariantCulture = string.Create(
System.Globalization.CultureInfo.InvariantCulture, $"The speed of light is {speedOfLight:N3} km/s.");
Console.WriteLine($"{System.Globalization.CultureInfo.CurrentCulture,-10} {messageInCurrentCulture}");
Console.WriteLine($"{specificCulture,-10} {messageInSpecificCulture}");
Console.WriteLine($"{"Invariant",-10} {messageInInvariantCulture}");
// Output is:
// nl-NL The speed of light is 299.792,458 km/s.
// en-IN The speed of light is 2,99,792.458 km/s.
// Invariant The speed of light is 299,792.458 km/s.
在 .NET 5 和更早的 .NET 版本中,请使用从内插字符串到 FormattableString 实例的隐式转换。 然后,可以使用实例 FormattableString.ToString(IFormatProvider) 方法或静态 FormattableString.Invariant 方法来生成特定于区域性的结果字符串。 下面的示例演示如何执行此操作:
c#
double speedOfLight = 299792.458;
FormattableString message = $"The speed of light is {speedOfLight:N3} km/s.";
var specificCulture = System.Globalization.CultureInfo.GetCultureInfo("en-IN");
string messageInSpecificCulture = message.ToString(specificCulture);
Console.WriteLine(messageInSpecificCulture);
// Output:
// The speed of light is 2,99,792.458 km/s.
string messageInInvariantCulture = FormattableString.Invariant(message);
Console.WriteLine(messageInInvariantCulture);
// Output is:
// The speed of light is 299,792.458 km/s.
有关自定义格式设置的详细信息,请参阅在 .NET 中设置类型格式一文中的使用 ICustomFormatter 进行自定义格式设置部分。
其他资源
如果你不熟悉字符串内插,请参阅 C# 中的字符串内插交互式教程。 还可查看另一个 C# 中的字符串内插教程。 该教程演示了如何使用内插字符串生成带格式的字符串。
内插字符串编译
从 C# 10 和 .NET 6 开始,编译器会检查内插字符串是否被分配给满足内插字符串处理程序模式要求的类型。 内插字符串处理程序是一种将内插字符串转换为结果字符串的类型。 当内插字符串的类型为 string
时,它由 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler 处理。 有关自定义内插字符串处理程序的示例,请参阅编写自定义字符串内插处理程序教程。 使用内插字符串处理程序是一种高级方案,通常出于性能原因而需要使用。
内插字符串处理程序的一个副作用是,自定义处理程序(包括 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler)可能不会在所有条件下都计算内插字符串中的所有内插表达式。 这意味着这些表达式的副作用可能不会发生。
在 C# 10 之前,如果内插字符串类型为 string
,则通常将其转换为 String.Format 方法调用。 如果分析的行为等同于串联,则编译器可将 String.Format 替换为 String.Concat。
如果内插字符串类型为 IFormattable 或 FormattableString,则编译器会生成对 FormattableStringFactory.Create 方法的调用。
逐字文本 - @
在变量、属性和字符串文本中
@
特殊字符用作原义标识符。 通过以下方式使用它:
- 指示将原义解释字符串。
@
字符在此实例中定义原义标识符 。 简单转义序列(如代表反斜杠的"\\"
)、十六进制转义序列(如代表大写字母 A 的"\x0041"
)和 Unicode 转义序列(如代表大写字母 A 的"\u0041"
)都将按字面解释。 只有引号转义序列 (""
) 不会按字面解释;因为它生成一个双引号。 此外,如果是逐字内插字符串,大括号转义序列({``{
和}}
)不按字面解释;它们会生成单个大括号字符。 下面的示例分别使用常规字符串和原义字符串定义两个相同的文件路径。 这是原义字符串的较常见用法之一。
c#
string filename1 = @"c:\documents\files\u0066.txt";
string filename2 = "c:\\documents\\files\\u0066.txt";
Console.WriteLine(filename1);
Console.WriteLine(filename2);
// The example displays the following output:
// c:\documents\files\u0066.txt
// c:\documents\files\u0066.txt
下面的示例演示定义包含相同字符序列的常规字符串和原义字符串的效果。
c#
string s1 = "He said, \"This is the last \u0063hance\x0021\"";
string s2 = @"He said, ""This is the last \u0063hance\x0021""";
Console.WriteLine(s1);
Console.WriteLine(s2);
// The example displays the following output:
// He said, "This is the last chance!"
// He said, "This is the last \u0063hance\x0021"
- 使用 C# 关键字作为标识符。
@
字符可作为代码元素的前缀,编译器将把此代码元素解释为标识符而非 C# 关键字。 下面的示例使用@
字符定义其在for
循环中使用的名为for
的标识符。
c#
string[] @for = { "John", "James", "Joan", "Jamie" };
for (int ctr = 0; ctr < @for.Length; ctr++)
{
Console.WriteLine($"Here is your gift, {@for[ctr]}!");
}
// The example displays the following output:
// Here is your gift, John!
// Here is your gift, James!
// Here is your gift, Joan!
// Here is your gift, Jamie!
- 使编译器在命名冲突的情况下区分两种属性。 属性是派生自 Attribute 的类。 其类型名称通常包含后缀 Attribute,但编译器不会强制进行此转换。 随后可在代码中按其完整类型名称(例如
[InfoAttribute]
)或短名称(例如[Info]
)引用此属性。 但是,如果两个短名称相同,并且一个类型名称包含 Attribute 后缀而另一类型名称不包含,则会出现命名冲突。 例如,由于编译器无法确定将Info
还是InfoAttribute
属性应用于Example
类,因此下面的代码无法编译。 有关详细信息,请参阅 CS1614。
c#
using System;
[AttributeUsage(AttributeTargets.Class)]
public class Info : Attribute
{
private string information;
public Info(string info)
{
information = info;
}
}
[AttributeUsage(AttributeTargets.Method)]
public class InfoAttribute : Attribute
{
private string information;
public InfoAttribute(string info)
{
information = info;
}
}
[Info("A simple executable.")] // Generates compiler error CS1614. Ambiguous Info and InfoAttribute.
// Prepend '@' to select 'Info' ([@Info("A simple executable.")]). Specify the full name 'InfoAttribute' to select it.
public class Example
{
[InfoAttribute("The entry point.")]
public static void Main()
{
}
}
决策与分支
选择语句 - if
、if-else
和 switch
if
、if-else
和 switch
语句根据表达式的值从多个可能的语句选择要执行的路径。 仅当提供的布尔表达式的计算结果为 true
时,if
,if
语句才执行语句。 语句 if-else
允许你根据布尔表达式选择要遵循的两个代码路径中的哪一个。 switch
语句根据与表达式匹配的模式来选择要执行的语句列表。
if
语句
if
语句可采用以下两种形式中的任一种:
- 包含
else
部分的if
语句根据布尔表达式的值选择两个语句中的一个来执行,如以下示例所示:
c#
DisplayWeatherReport(15.0); // Output: Cold.
DisplayWeatherReport(24.0); // Output: Perfect!
void DisplayWeatherReport(double tempInCelsius)
{
if (tempInCelsius < 20.0)
{
Console.WriteLine("Cold.");
}
else
{
Console.WriteLine("Perfect!");
}
}
- 不包含
else
部分的if
语句仅在布尔表达式计算结果为true
时执行其主体,如以下示例所示:
c#
DisplayMeasurement(45); // Output: The measurement value is 45
DisplayMeasurement(-3); // Output: Warning: not acceptable value! The measurement value is -3
void DisplayMeasurement(double value)
{
if (value < 0 || value > 100)
{
Console.Write("Warning: not acceptable value! ");
}
Console.WriteLine($"The measurement value is {value}");
}
可嵌套 if
语句来检查多个条件,如以下示例所示:
c#
DisplayCharacter('f'); // Output: A lowercase letter: f
DisplayCharacter('R'); // Output: An uppercase letter: R
DisplayCharacter('8'); // Output: A digit: 8
DisplayCharacter(','); // Output: Not alphanumeric character: ,
void DisplayCharacter(char ch)
{
if (char.IsUpper(ch))
{
Console.WriteLine($"An uppercase letter: {ch}");
}
else if (char.IsLower(ch))
{
Console.WriteLine($"A lowercase letter: {ch}");
}
else if (char.IsDigit(ch))
{
Console.WriteLine($"A digit: {ch}");
}
else
{
Console.WriteLine($"Not alphanumeric character: {ch}");
}
}
在表达式上下文中,可使用条件运算符 ?:
根据布尔表达式的值计算两个表达式中的一个。
switch
语句
switch
语句根据与匹配表达式匹配的模式来选择要执行的语句列表,如以下示例所示:
c#
DisplayMeasurement(-4); // Output: Measured value is -4; too low.
DisplayMeasurement(5); // Output: Measured value is 5.
DisplayMeasurement(30); // Output: Measured value is 30; too high.
DisplayMeasurement(double.NaN); // Output: Failed measurement.
void DisplayMeasurement(double measurement)
{
switch (measurement)
{
case < 0.0:
Console.WriteLine($"Measured value is {measurement}; too low.");
break;
case > 15.0:
Console.WriteLine($"Measured value is {measurement}; too high.");
break;
case double.NaN:
Console.WriteLine("Failed measurement.");
break;
default:
Console.WriteLine($"Measured value is {measurement}.");
break;
}
}
在上述示例中,switch
语句使用以下模式:
重要
有关
switch
语句支持的模式的信息,请参阅模式。
上述示例还展示了 default
case。 default
case 指定匹配表达式与其他任何 case 模式都不匹配时要执行的语句。 如果匹配表达式与任何 case 模式都不匹配,且没有 default
case,控制就会贯穿 switch
语句。
switch
语句执行第一个 switch 部分中的语句列表,其 case 模式与匹配表达式匹配,并且它的 case guard(如果存在)求值为 true
。 switch
语句按文本顺序从上到下对 case 模式求值。 编译器在 switch
语句包含无法访问的 case 时会生成错误。 这种 case 已由大写字母处理或其模式无法匹配。
备注
default
case 可以在switch
语句的任何位置出现。 无论其位置如何,仅当所有其他事例模式都不匹配或goto default;
语句在其中一个 switch 节中执行时,default
才会计算事例。
可以为 switch
语句的一部分指定多个 case 模式,如以下示例所示:
c#
DisplayMeasurement(-4); // Output: Measured value is -4; out of an acceptable range.
DisplayMeasurement(50); // Output: Measured value is 50.
DisplayMeasurement(132); // Output: Measured value is 132; out of an acceptable range.
void DisplayMeasurement(int measurement)
{
switch (measurement)
{
case < 0:
case > 100:
Console.WriteLine($"Measured value is {measurement}; out of an acceptable range.");
break;
default:
Console.WriteLine($"Measured value is {measurement}.");
break;
}
}
在 switch
语句中,控制不能从一个 switch 部分贯穿到下一个 switch 部分。 如本部分中的示例所示,通常使用每个 switch 部分末尾的 break
语句将控制从 switch
语句传递出去。 还可使用 return 和 throw 语句将控制从 switch
语句传递出去。 若要模拟贯穿行为,将控制传递给其他 switch 部分,可使用 goto
语句。
在表达式上下文中,可使用 switch
表达式,根据与表达式匹配的模式,对候选表达式列表中的单个表达式进行求值。
Case guard
case 模式可能表达功能不够,无法指定用于执行 switch 部分的条件。 在这种情况下,可以使用 case guard。 这是一个附加条件,必须与匹配模式同时满足。 case guard 必须是布尔表达式。 可以在模式后面的 when
关键字之后指定一个 case guard,如以下示例所示:
c#
DisplayMeasurements(3, 4); // Output: First measurement is 3, second measurement is 4.
DisplayMeasurements(5, 5); // Output: Both measurements are valid and equal to 5.
void DisplayMeasurements(int a, int b)
{
switch ((a, b))
{
case (> 0, > 0) when a == b:
Console.WriteLine($"Both measurements are valid and equal to {a}.");
break;
case (> 0, > 0):
Console.WriteLine($"First measurement is {a}, second measurement is {b}.");
break;
default:
Console.WriteLine("One or both measurements are not valid.");
break;
}
}
?: 运算符 - 三元条件运算符
条件运算符 (?:
) 也称为三元条件运算符,用于计算布尔表达式,并根据布尔表达式的计算结果为 true
还是 false
来返回两个表达式中的一个结果,如以下示例所示:
c#
string GetWeatherDisplay(double tempInCelsius) => tempInCelsius < 20.0 ? "Cold." : "Perfect!";
Console.WriteLine(GetWeatherDisplay(15)); // output: Cold.
Console.WriteLine(GetWeatherDisplay(27)); // output: Perfect!
如上述示例所示,条件运算符的语法如下所示:
c#
condition ? consequent : alternative
condition
表达式的计算结果必须为 true
或 false
。 若 condition
的计算结果为 true
,将计算 consequent
,其结果成为运算结果。 若 condition
的计算结果为 false
,将计算 alternative
,其结果成为运算结果。 只会计算 consequent
或 alternative
。 条件表达式是目标类型的。 也就是说,如果条件表达式的目标类型是已知的,则 consequent
和 alternative
的类型必须可隐式转换为目标类型,如以下示例所示:
c#
var rand = new Random();
var condition = rand.NextDouble() > 0.5;
int? x = condition ? 12 : null;
IEnumerable<int> xs = x is null ? new List<int>() { 0, 1 } : new int[] { 2, 3 };
如果条件表达式的目标类型未知(例如使用 var
关键字时)或 consequent
和 alternative
的类型必须相同,或者必须存在从一种类型到另一种类型的隐式转换:
c#
var rand = new Random();
var condition = rand.NextDouble() > 0.5;
var x = condition ? 12 : (int?)null;
条件运算符为右联运算符,即形式的表达式
c#
a ? b : c ? d : e
计算结果为
c#
a ? b : (c ? d : e)
- 提示
可以使用以下助记键设备记住条件运算符的计算方式:
c#is this condition true ? yes : no
ref 条件表达式
条件 ref 表达式可有条件地返回变量引用,如以下示例所示:
c#
int[] smallArray = [1, 2, 3, 4, 5];
int[] largeArray = [10, 20, 30, 40, 50];
int index = 7;
ref int refValue = ref ((index < 5) ? ref smallArray[index] : ref largeArray[index - 5]);
refValue = 0;
index = 2;
((index < 5) ? ref smallArray[index] : ref largeArray[index - 5]) = 100;
Console.WriteLine(string.Join(" ", smallArray));
Console.WriteLine(string.Join(" ", largeArray));
// Output:
// 1 2 100 4 5
// 10 20 0 40 50
可以ref
分配条件 ref 表达式的结果,将其用作引用返回,或将其作为 ref
、out
、in
或 ref readonly
方法参数传递。 还可以分配条件 ref 表达式的结果,如前面的示例所示。
ref 条件表达式的语法如下所示:
c#
condition ? ref consequent : ref alternative
条件 ref 表达式与条件运算符相似,仅计算两个表达式其中之一:consequent
或 alternative
。
在 ref 条件表达式中,consequent
和 alternative
的类型必须相同。 ref 条件表达式不由目标确定类型。
条件运算符和 if
语句
需要根据条件计算值时,使用条件运算符而不是 if
语句可以使代码更简洁。 下面的示例演示了将整数归类为负数或非负数的两种方法:
c#
int input = new Random().Next(-5, 5);
string classify;
if (input >= 0)
{
classify = "nonnegative";
}
else
{
classify = "negative";
}
classify = (input >= 0) ? "nonnegative" : "negative";
运算符可重载性
用户定义类型不能重载条件运算符。
程序循环
迭代语句用于遍历集合(如数组),或重复执行同一组语句直到满足指定的条件。 有关详细信息,请参阅下列主题:
此迭代语句重复执行语句或语句块。 for
语句:在指定的布尔表达式的计算结果为 true
时会执行其主体。 foreach
语句:枚举集合元素并对集合中的每个元素执行其主体。 do
语句:有条件地执行其主体一次或多次。 while
语句:有条件地执行其主体零次或多次。
在迭代语句体中的任何点,都可以使用 break
语句跳出循环。 可以使用 continue
语句进入循环中的下一个迭代。
for
语句
在指定的布尔表达式的计算结果为 true
时,for
语句会执行一条语句或一个语句块。 以下示例显示了 for
语句,该语句在整数计数器小于 3 时执行其主体:
c#
for (int i = 0; i < 3; i++)
{
Console.Write(i);
}
// Output:
// 012
上述示例展示了 for
语句的元素:
-
"初始化表达式"部分仅在进入循环前执行一次。 通常,在该部分中声明并初始化局部循环变量。 不能从
for
语句外部访问声明的变量。上例中的"初始化表达式"部分声明并初始化整数计数器变量:
int i = 0
-
"条件"部分确定是否应执行循环中的下一个迭代。 如果计算结果为
true
或不存在,则执行下一个迭代;否则退出循环。 "条件"部分必须为布尔表达式。上例中的"条件"条件部分检查计数器值是否小于 3:
i < 3
-
"迭代器"部分定义循环主体的每次执行后将执行的操作。
上例中的"迭代器"部分增加计数器:
i++
-
循环体,必须是一个语句或一个语句块。
迭代器"部分可包含用逗号分隔的零个或多个以下语句表达式:
- 为 increment 表达式添加前缀或后缀,如
++i
或i++
- 为 decrement 表达式添加前缀或后缀,如
--i
或i--
- assignment
- 方法的调用
await
表达式- 通过使用
new
运算符来创建对象
如果未在"初始化表达式"部分中声明循环变量,则还可以在"初始化表达式"部分中使用上述列表中的零个或多个表达式。 下面的示例显示了几种不太常见的"初始化表达式"和"迭代器"部分的使用情况:为"初始化表达式"部分中的外部变量赋值、同时在"初始化表达式"部分和"迭代器"部分中调用一种方法,以及更改"迭代器"部分中的两个变量的值:
c#
int i;
int j = 3;
for (i = 0, Console.WriteLine($"Start: i={i}, j={j}"); i < j; i++, j--, Console.WriteLine($"Step: i={i}, j={j}"))
{
//...
}
// Output:
// Start: i=0, j=3
// Step: i=1, j=2
// Step: i=2, j=1
for
语句的所有部分都是可选的。 例如,以下代码定义无限 for
循环:
c#
for ( ; ; )
{
//...
}
foreach
语句
foreach
语句为类型实例中实现 System.Collections.IEnumerable 或 System.Collections.Generic.IEnumerable 接口的每个元素执行语句或语句块,如以下示例所示:
c#
List<int> fibNumbers = [0, 1, 1, 2, 3, 5, 8, 13];
foreach (int element in fibNumbers)
{
Console.Write($"{element} ");
}
// Output:
// 0 1 1 2 3 5 8 13
foreach
语句并不限于这些类型。 可以将其与满足以下条件的任何类型的实例一起使用:
- 类型具有公共无参数
GetEnumerator
方法。GetEnumerator
方法可以是类型的扩展方法。 GetEnumerator
方法的返回类型具有公共Current
属性和公共无参数MoveNext
方法(其返回类型为bool
)。
下面的示例使用 foreach
语句,其中包含 System.Span 类型的实例,该实例不实现任何接口:
c#
Span<int> numbers = [3, 14, 15, 92, 6];
foreach (int number in numbers)
{
Console.Write($"{number} ");
}
// Output:
// 3 14 15 92 6
如果枚举器的 Current
属性返回引用返回值(ref T
,其中 T
为集合元素类型),就可以使用 ref
或 ref readonly
修饰符来声明迭代变量,如下面的示例所示:
c#
Span<int> storage = stackalloc int[10];
int num = 0;
foreach (ref int item in storage)
{
item = num++;
}
foreach (ref readonly var item in storage)
{
Console.Write($"{item} ");
}
// Output:
// 0 1 2 3 4 5 6 7 8 9
如果 foreach
语句的源集合为空,则 foreach
语句的正文不会被执行,而是被跳过。 如果 foreach
语句应用为 null
,则会引发 NullReferenceException。
await foreach
可以使用 await foreach
语句来使用异步数据流,即实现 IAsyncEnumerable 接口的集合类型。 异步检索下一个元素时,可能会挂起循环的每次迭代。 下面的示例演示如何使用 await foreach
语句:
c#
await foreach (var item in GenerateSequenceAsync())
{
Console.WriteLine(item);
}
还可以将 await foreach
语句与满足以下条件的任何类型的实例一起使用:
- 类型具有公共无参数
GetAsyncEnumerator
方法。 该方法可以是类型的扩展方法。 GetAsyncEnumerator
方法的返回类型具有公共Current
属性和公共无参数MoveNextAsync
方法(其返回类型为Task
、ValueTask
或任何其他可等待类型,其 awaiter 的GetResult
方法返回bool
值)。
默认情况下,在捕获的上下文中处理流元素。 如果要禁用上下文捕获,请使用 TaskAsyncEnumerableExtensions.ConfigureAwait 扩展方法。 有关同步上下文并捕获当前上下文的详细信息,请参阅使用基于任务的异步模式。 有关异步流的详细信息,请参阅异步流教程。
迭代变量的类型
可以使用 var
关键字让编译器推断 foreach
语句中迭代变量的类型,如以下代码所示:
c#
foreach (var item in collection) { }
备注
译器可以将
var
的类型推断为可为空的引用类型,具体取决于是否启用可为空的感知上下文以及初始化表达式的类型是否为引用类型。 有关详细信息,请参阅隐式类型本地变量。
还可以显式指定迭代变量的类型,如以下代码所示:
c#
IEnumerable<T> collection = new T[5];
foreach (V item in collection) { }
在上述窗体中,集合元素的类型 T
必须可隐式或显式地转换为迭代变量的类型 V
。 如果从 T
到 V
的显式转换在运行时失败,foreach
语句将引发 InvalidCastException。 例如,如果 T
是非密封类类型,则 V
可以是任何接口类型,甚至可以是 T
未实现的接口类型。 在运行时,集合元素的类型可以是从 T
派生并且实际实现 V
的类型。 如果不是这样,则会引发 InvalidCastException。
do
语句
在指定的布尔表达式的计算结果为 true
时,do
语句会执行一条语句或一个语句块。 由于在每次执行循环之后都会计算此表达式,所以 do
循环会执行一次或多次。 do
循环不同于 while
循环(该循环执行零次或多次)。
下面的示例演示 do
语句的用法:
c#
int n = 0;
do
{
Console.Write(n);
n++;
} while (n < 5);
// Output:
// 01234
while
语句
在指定的布尔表达式的计算结果为 true
时,while
语句会执行一条语句或一个语句块。 由于在每次执行循环之前都会计算此表达式,所以 while
循环会执行零次或多次。 while
循环不同于 do
循环(该循环执行 1 次或多次)。
下面的示例演示 while
语句的用法:
c#
int n = 0;
while (n < 5)
{
Console.Write(n);
n++;
}
// Output:
// 01234
C# 方法
方法是包含一系列语句的代码块。 程序通过调用该方法并指定任何所需的方法参数使语句得以执行。 在 C# 中,每个执行的指令均在方法的上下文中执行。 Main
方法是每个 C# 应用程序的入口点,并在启动程序时由公共语言运行时 (CLR) 调用。
备注
本主题讨论命名的方法。 有关匿名函数的信息,请参阅 Lambda 表达式。
方法签名
通过指定以下内容在 class
、record
或 struct
中声明方法:
- 可选的访问级别,如
public
或private
。 默认值为private
。 - 可选的修饰符,如
abstract
或sealed
。 - 返回值,或
void
(如果该方法不具有)。 - 方法名称。
- 任何方法参数。 方法参数在括号内,并且用逗号分隔。 空括号指示方法不需要任何参数。
这些部分一同构成方法签名。
重要
出于方法重载的目的,方法的返回类型不是方法签名的一部分。 但是在确定委托和它所指向的方法之间的兼容性时,它是方法签名的一部分。
以下实例定义了一个包含五种方法的名为 Motorcycle
的类:
c#
namespace MotorCycleExample
{
abstract class Motorcycle
{
// Anyone can call this.
public void StartEngine() {/* Method statements here */ }
// Only derived classes can call this.
protected void AddGas(int gallons) { /* Method statements here */ }
// Derived classes can override the base class implementation.
public virtual int Drive(int miles, int speed) { /* Method statements here */ return 1; }
// Derived classes can override the base class implementation.
public virtual int Drive(TimeSpan time, int speed) { /* Method statements here */ return 0; }
// Derived classes must implement this.
public abstract double GetTopSpeed();
}
Motorcycle
类包括一个重载的方法 Drive
。 两个方法具有相同的名称,但必须根据其参数类型来区分。
方法调用
方法可以是实例的或静态的。 调用实例方法需要将对象实例化,并对该对象调用方法;实例方法可对该实例及其数据进行操作。 通过引用该方法所属类型的名称来调用静态方法;静态方法不对实例数据进行操作。 尝试通过对象实例调用静态方法会引发编译器错误。
调用方法就像访问字段。 在对象名称(如果调用实例方法)或类型名称(如果调用 static
方法)后添加一个句点、方法名称和括号。 自变量列在括号里,并且用逗号分隔。
该方法定义指定任何所需参数的名称和类型。 调用方调用该方法时,它为每个参数提供了称为自变量的具体值。 实参必须与形参类型兼容,但调用代码中使用的实参名(如果有)不需要与方法中定义的形参名相同。 在下面示例中,Square
方法包含名为 i 的类型为 int
的单个参数。 第一种方法调用将向 Square
方法传递名为 num 的 int
类型的变量;第二个方法调用将传递数值常量;第三个方法调用将传递表达式。
c#
public static class SquareExample
{
public static void Main()
{
// Call with an int variable.
int num = 4;
int productA = Square(num);
// Call with an integer literal.
int productB = Square(12);
// Call with an expression that evaluates to int.
int productC = Square(productA * 3);
}
static int Square(int i)
{
// Store input argument in a local variable.
int input = i;
return input * input;
}
}
方法调用最常见的形式是使用位置自变量;它会以与方法参数相同的顺序提供自变量。 因此,可在以下示例中调用 Motorcycle
类的方法。 例如,Drive
方法的调用包含两个与方法语法中的两个参数对应的自变量。 第一个成为 miles
参数的值,第二个成为 speed
参数的值。
c#
class TestMotorcycle : Motorcycle
{
public override double GetTopSpeed() => 108.4;
static void Main()
{
var moto = new TestMotorcycle();
moto.StartEngine();
moto.AddGas(15);
_ = moto.Drive(5, 20);
double speed = moto.GetTopSpeed();
Console.WriteLine("My top speed is {0}", speed);
}
}
调用方法时,也可以使用命名的自变量,而不是位置自变量。 使用命名的自变量时,指定参数名,然后后跟冒号(":")和自变量。 只要包含了所有必需的自变量,方法的自变量可以任何顺序出现。 下面的示例使用命名的自变量来调用 TestMotorcycle.Drive
方法。 在此示例中,命名的自变量以相反于方法参数列表中的顺序进行传递。
c#
namespace NamedMotorCycle;
class TestMotorcycle : Motorcycle
{
public override int Drive(int miles, int speed) =>
(int)Math.Round((double)miles / speed, 0);
public override double GetTopSpeed() => 108.4;
static void Main()
{
var moto = new TestMotorcycle();
moto.StartEngine();
moto.AddGas(15);
int travelTime = moto.Drive(miles: 170, speed: 60);
Console.WriteLine("Travel time: approx. {0} hours", travelTime);
}
}
// The example displays the following output:
// Travel time: approx. 3 hours
可以同时使用位置自变量和命名的自变量调用方法。 但是,只有当命名参数位于正确位置时,才能在命名自变量后面放置位置参数。 下面的示例使用一个位置自变量和一个命名的自变量从上一个示例中调用 TestMotorcycle.Drive
方法。
c#
int travelTime = moto.Drive(170, speed: 55);
继承和重写方法
除了类型中显式定义的成员,类型还继承在其基类中定义的成员。 由于托管类型系统中的所有类型都直接或间接继承自 Object 类,因此所有类型都继承其成员,如 Equals(Object)、GetType() 和 ToString()。 下面的示例定义 Person
类,实例化两个 Person
对象,并调用 Person.Equals
方法来确定两个对象是否相等。 但是,Equals
方法不是在 Person
类中定义;而是继承自 Object。
c#
public class Person
{
public string FirstName = default!;
}
public static class ClassTypeExample
{
public static void Main()
{
Person p1 = new() { FirstName = "John" };
Person p2 = new() { FirstName = "John" };
Console.WriteLine("p1 = p2: {0}", p1.Equals(p2));
}
}
// The example displays the following output:
// p1 = p2: False
类型可以使用 override
关键字并提供重写方法的实现来重写继承的成员。 方法签名必须与重写的方法的签名一样。 下面的示例类似于上一个示例,只不过它重写 Equals(Object) 方法。 (它还重写 GetHashCode() 方法,因为这两种方法用于提供一致的结果。)
c#
namespace methods;
public class Person
{
public string FirstName = default!;
public override bool Equals(object? obj) =>
obj is Person p2 &&
FirstName.Equals(p2.FirstName);
public override int GetHashCode() => FirstName.GetHashCode();
}
public static class Example
{
public static void Main()
{
Person p1 = new() { FirstName = "John" };
Person p2 = new() { FirstName = "John" };
Console.WriteLine("p1 = p2: {0}", p1.Equals(p2));
}
}
// The example displays the following output:
// p1 = p2: True
快速参考
C# 中的所有类型不是值类型就是引用类型。 有关内置值类型的列表,请参阅类型。 默认情况下,值类型和引用类型均按值传递给方法。
按值传递参数
值类型按值传递给方法时,传递的是对象的副本而不是对象本身。 因此,当控件返回调用方时,对已调用方法中的对象的更改对原始对象无影响。
下面的示例按值向方法传递值类型,且调用的方法尝试更改值类型的值。 它定义属于值类型的 int
类型的变量,将其值初始化为 20,并将该类型传递给将变量值改为 30 的名为 ModifyValue
的方法。 但是,返回方法时,变量的值保持不变。
c#
public static class ByValueExample
{
public static void Main()
{
var value = 20;
Console.WriteLine("In Main, value = {0}", value);
ModifyValue(value);
Console.WriteLine("Back in Main, value = {0}", value);
}
static void ModifyValue(int i)
{
i = 30;
Console.WriteLine("In ModifyValue, parameter value = {0}", i);
return;
}
}
// The example displays the following output:
// In Main, value = 20
// In ModifyValue, parameter value = 30
// Back in Main, value = 20
引用类型的对象按值传递到方法中时,将按值传递对对象的引用。 也就是说,该方法接收的不是对象本身,而是指示该对象位置的自变量。 控件返回到调用方法时,如果通过使用此引用更改对象的成员,此更改将反映在对象中。 但是,当控件返回到调用方时,替换传递到方法的对象对原始对象无影响。
下面的示例定义名为 SampleRefType
的类(属于引用类型)。 它实例化 SampleRefType
对象,将 44 赋予其 value
字段,并将该对象传递给 ModifyObject
方法。 该示例执行的内容实质上与先前示例相同,即均按值将自变量传递到方法。 但因为使用了引用类型,结果会有所不同。 ModifyObject
中所做的对 obj.value
字段的修改,也会将 Main
方法中的自变量 rt
的 value
字段更改为 33,如示例中的输出值所示。
c#
public class SampleRefType
{
public int value;
}
public static class ByRefTypeExample
{
public static void Main()
{
var rt = new SampleRefType { value = 44 };
ModifyObject(rt);
Console.WriteLine(rt.value);
}
static void ModifyObject(SampleRefType obj) => obj.value = 33;
}
按引用传递参数
如果想要更改方法中的自变量值并想要在控件返回到调用方法时反映出这一更改,请按引用传递参数。 要按引用传递参数,请使用 ref
或 out
关键字。 还可以使用 in
关键字,按引用传递值以避免复制,但仍防止修改。
下面的示例与上一个示例完全一样,只是换成按引用将值传递给 ModifyValue
方法。 参数值在 ModifyValue
方法中修改时,值中的更改将在控件返回调用方时反映出来。
c#
public static class ByRefExample
{
public static void Main()
{
var value = 20;
Console.WriteLine("In Main, value = {0}", value);
ModifyValue(ref value);
Console.WriteLine("Back in Main, value = {0}", value);
}
private static void ModifyValue(ref int i)
{
i = 30;
Console.WriteLine("In ModifyValue, parameter value = {0}", i);
return;
}
}
// The example displays the following output:
// In Main, value = 20
// In ModifyValue, parameter value = 30
// Back in Main, value = 30
引用参数所使用的常见模式涉及交换变量值。 将两个变量按引用传递给一个方法,然后该方法将二者内容进行交换。 下面的示例交换整数值。
c#
public static class RefSwapExample
{
static void Main()
{
int i = 2, j = 3;
Console.WriteLine("i = {0} j = {1}", i, j);
Swap(ref i, ref j);
Console.WriteLine("i = {0} j = {1}", i, j);
}
static void Swap(ref int x, ref int y) =>
(y, x) = (x, y);
}
// The example displays the following output:
// i = 2 j = 3
// i = 3 j = 2
通过传递引用类型的参数,可以更改引用本身的值,而不是其单个元素或字段的值。
参数数组
有时,向方法指定精确数量的自变量这一要求是受限的。 通过使用 params
关键字来指示一个参数是一个参数数组,可通过可变数量的自变量来调用方法。 使用 params
关键字标记的参数必须为数组类型,并且必须是该方法的参数列表中的最后一个参数。
然后,调用方可通过以下四种方式中的任一种来调用方法:
- 传递相应类型的数组,该类型包含所需数量的元素。
- 向该方法传递相应类型的单独自变量的逗号分隔列表。
- 传递
null
。 - 不向参数数组提供参数。
以下示例定义了一个名为 GetVowels
的方法,该方法返回参数数组中的所有元音。 Main
方法演示了调用方法的全部四种方式。 调用方不需要为包含 params
修饰符的形参提供任何实参。 在这种情况下,参数是一个空数组。
c#
static class ParamsExample
{
static void Main()
{
string fromArray = GetVowels(["apple", "banana", "pear"]);
Console.WriteLine($"Vowels from array: '{fromArray}'");
string fromMultipleArguments = GetVowels("apple", "banana", "pear");
Console.WriteLine($"Vowels from multiple arguments: '{fromMultipleArguments}'");
string fromNull = GetVowels(null);
Console.WriteLine($"Vowels from null: '{fromNull}'");
string fromNoValue = GetVowels();
Console.WriteLine($"Vowels from no value: '{fromNoValue}'");
}
static string GetVowels(params string[]? input)
{
if (input == null || input.Length == 0)
{
return string.Empty;
}
char[] vowels = ['A', 'E', 'I', 'O', 'U'];
return string.Concat(
input.SelectMany(
word => word.Where(letter => vowels.Contains(char.ToUpper(letter)))));
}
}
// The example displays the following output:
// Vowels from array: 'aeaaaea'
// Vowels from multiple arguments: 'aeaaaea'
// Vowels from null: ''
// Vowels from no value: ''
可选参数和自变量
方法定义可指定其参数是必需的还是可选的。 默认情况下,参数是必需的。 通过在方法定义中包含参数的默认值来指定可选参数。 调用该方法时,如果未向可选参数提供自变量,则改为使用默认值。
参数的默认值必须由以下几种表达式中的一种来赋予:
- 常量,例如文本字符串或数字。
default(SomeType)
形式的表达式,其中SomeType
可以是值类型或引用类型。 如果是引用类型,那么它实际上与指定null
相同。 可以使用default
字面量,因为编译器可以从参数的声明中推断出类型。new ValType()
形式的表达式,其中ValType
是值类型。 这会调用该值类型的隐式无参数构造函数,该函数不是类型的实际成员。
备注
在 C# 10 及更高版本中,当
new ValType()
形式的表达式调用某一值类型的显式定义的无参数构造函数时,编译器便会生成错误,因为默认参数值必须是编译时常数。 使用default(ValType)
表达式或default
字面量提供默认参数值。 有关无参数构造函数的详细信息,请参阅结构类型一文的结构初始化和默认值部分。
如果某个方法同时包含必需的和可选的参数,则在参数列表末尾定义可选参数,即在定义完所有必需参数之后定义。
下面的示例定义方法 ExampleMethod
,它具有一个必需参数和两个可选参数。
c#
public class Options
{
public void ExampleMethod(int required, int optionalInt = default,
string? description = default)
{
var msg = $"{description ?? "N/A"}: {required} + {optionalInt} = {required + optionalInt}";
Console.WriteLine(msg);
}
}
如果使用位置自变量调用包含多个可选自变量的方法,调用方必须逐一向所有需要自变量的可选参数提供自变量。 例如,在使用 ExampleMethod
方法的情况下,如果调用方向 description
形参提供实参,还必须向 optionalInt
形参提供一个实参。 opt.ExampleMethod(2, 2, "Addition of 2 and 2");
是一个有效的方法调用;opt.ExampleMethod(2, , "Addition of 2 and 0");
生成编译器错误"缺少自变量"。
如果使用命名的自变量或位置自变量和命名的自变量的组合来调用某个方法,调用方可以省略方法调用中的最后一个位置自变量后的任何自变量。
下面的示例三次调用了 ExampleMethod
方法。 前两个方法调用使用位置自变量。 第一个方法同时省略了两个可选自变量,而第二个省略了最后一个自变量。 第三个方法调用向必需的参数提供位置自变量,但使用命名的自变量向 description
参数提供值,同时省略 optionalInt
自变量。
c#
public static class OptionsExample
{
public static void Main()
{
var opt = new Options();
opt.ExampleMethod(10);
opt.ExampleMethod(10, 2);
opt.ExampleMethod(12, description: "Addition with zero:");
}
}
// The example displays the following output:
// N/A: 10 + 0 = 10
// N/A: 10 + 2 = 12
// Addition with zero:: 12 + 0 = 12
使用可选参数会影响重载决策,或影响 C# 编译器决定方法应调用哪个特定重载时所使用的方式,如下所示:
- 如果方法、索引器或构造函数的每个参数是可选的,或按名称或位置对应于调用语句中的单个自变量,且该自变量可转换为参数的类型,则方法、索引器或构造函数为执行的候选项。
- 如果找到多个候选项,则会将用于首选转换的重载决策规则应用于显式指定的自变量。 将忽略可选形参已省略的实参。
- 如果两个候选项不相上下,则会将没有可选形参的候选项作为首选项,对于这些可选形参,已在调用中为其省略了实参。 这是重载决策中的常规引用的结果,该引用用于参数较少的候选项。
返回值
方法可以将值返回到调用方。 如果列在方法名之前的返回类型不是 void
,则该方法可通过使用 return
关键字返回值。 带 return
关键字且后跟与返回类型匹配的变量、常数或表达式的语句将向方法调用方返回该值。 具有非空的返回类型的方法都需要使用 return
关键字来返回值。 return
关键字还会停止执行该方法。
如果返回类型为 void
,没有值的 return
语句仍可用于停止执行该方法。 没有 return
关键字,当方法到达代码块结尾时,将停止执行。
例如,这两种方法都使用 return
关键字来返回整数:
c#
class SimpleMath
{
public int AddTwoNumbers(int number1, int number2) =>
number1 + number2;
public int SquareANumber(int number) =>
number * number;
}
若要使用从方法返回的值,调用方法可以在相同类型的值足够的地方使用该方法调用本身。 也可以将返回值分配给变量。 例如,以下两个代码示例实现了相同的目标:
c#
int result = obj.AddTwoNumbers(1, 2);
result = obj.SquareANumber(result);
// The result is 9.
Console.WriteLine(result);
c#
result = obj.SquareANumber(obj.AddTwoNumbers(1, 2));
// The result is 9.
Console.WriteLine(result);
在这种情况下,使用本地变量 result
存储值是可选的。 此步骤可以帮助提高代码的可读性,或者如果需要存储该方法整个范围内自变量的原始值,则此步骤可能很有必要。
有时,需要方法返回多个值。 可以使用"元组类型"和"元组文本"轻松执行此操作。 元组类型定义元组元素的数据类型。 元组文本提供返回的元组的实际值。 在下面的示例中,(string, string, string, int)
定义 GetPersonalInfo
方法返回的元组类型。 表达式 (per.FirstName, per.MiddleName, per.LastName, per.Age)
是元组文本;方法返回 PersonInfo
对象的第一个、中间和最后一个名称及其使用期限。
c#
public (string, string, string, int) GetPersonalInfo(string id)
{
PersonInfo per = PersonInfo.RetrieveInfoById(id);
return (per.FirstName, per.MiddleName, per.LastName, per.Age);
}
然后调用方可通过类似以下的代码使用返回的元组:
c#
var person = GetPersonalInfo("111111111");
Console.WriteLine($"{person.Item1} {person.Item3}: age = {person.Item4}");
还可向元组类型定义中的元组元素分配名称。 下面的示例展示 GetPersonalInfo
方法的替代版本,该方法使用命名的元素:
c#
public (string FName, string MName, string LName, int Age) GetPersonalInfo(string id)
{
PersonInfo per = PersonInfo.RetrieveInfoById(id);
return (per.FirstName, per.MiddleName, per.LastName, per.Age);
}
然后可修改上一次对 GetPersonalInfo
方法的调用,如下所示:
c#
var person = GetPersonalInfo("111111111");
Console.WriteLine($"{person.FName} {person.LName}: age = {person.Age}");
如果将数组作为自变量传递给一个方法,并修改各个元素的值,则该方法不一定会返回该数组,尽管选择这么操作的原因是为了实现更好的样式或功能性的值流。 这是因为 C# 会按值传递所有引用类型,而数组引用的值是指向该数组的指针。 在下面的示例中,引用该数组的任何代码都能观察到在 DoubleValues
方法中对 values
数组内容的更改。
c#
public static class ArrayValueExample
{
static void Main()
{
int[] values = [2, 4, 6, 8];
DoubleValues(values);
foreach (var value in values)
{
Console.Write("{0} ", value);
}
}
public static void DoubleValues(int[] arr)
{
for (var ctr = 0; ctr <= arr.GetUpperBound(0); ctr++)
{
arr[ctr] *= 2;
}
}
}
// The example displays the following output:
// 4 8 12 16
扩展方法
通常,可以通过两种方式向现有类型添加方法:
- 修改该类型的源代码。 当然,如果并不拥有该类型的源代码,则无法执行该操作。 并且,如果还添加任何专用数据字段来支持该方法,这会成为一项重大更改。
- 在派生类中定义新方法。 无法使用其他类型(如结构和枚举)的继承来通过此方式添加方法。 也不能使用此方式向封闭类"添加"方法。
使用扩展方法,可向现有类型"添加"方法,而无需修改类型本身或在继承的类型中实现新方法。 扩展方法也无需驻留在与其扩展的类型相同的程序集中。 要把扩展方法当作是定义的类型成员一样调用。
有关详细信息,请参阅扩展方法
异步方法
通过使用异步功能,你可以调用异步方法而无需使用显式回调,也不需要跨多个方法或 lambda 表达式来手动拆分代码。
如果用 async 修饰符标记方法,则可以在该方法中使用 await 运算符。 当控件到达异步方法中的 await
表达式时,如果等待的任务未完成,控件将返回到调用方,并在等待任务完成前,包含 await
关键字的方法中的进度将一直处于挂起状态。 任务完成后,可以在方法中恢复执行。
备注
异步方法在遇到第一个尚未完成的 awaited 对象或到达异步方法的末尾时(以先发生者为准),将返回到调用方。
异步方法通常具有 Task、Task、IAsyncEnumerable 或 void
返回类型。 void
返回类型主要用于定义需要 void
返回类型的事件处理程序。 无法等待返回 void
的异步方法,并且返回 void 方法的调用方无法捕获该方法引发的异常。 异步方法可以具有任何类似任务的返回类型。
在下面的示例中,DelayAsync
是一个异步方法,包含返回整数的 return 语句。 由于它是异步方法,其方法声明必须具有返回类型 Task<int>
。 因为返回类型是 Task<int>
,DoSomethingAsync
中 await
表达式的计算将如以下 int result = await delayTask
语句所示得出整数。
csharp
class Program
{
static Task Main() => DoSomethingAsync();
static async Task DoSomethingAsync()
{
Task<int> delayTask = DelayAsync();
int result = await delayTask;
// The previous two statements may be combined into
// the following statement.
//int result = await DelayAsync();
Console.WriteLine($"Result: {result}");
}
static async Task<int> DelayAsync()
{
await Task.Delay(100);
return 5;
}
}
// Example output:
// Result: 5
异步方法不能声明任何 in、ref 或 out 参数,但是可以调用具有这类参数的方法。
有关异步方法的详细信息,请参阅使用 Async 和 Await 的异步编程和异步返回类型。
Expression-Bodied 成员
具有立即仅返回表达式结果,或单个语句作为方法主题的方法定义很常见。 以下是使用 =>
定义此类方法的语法快捷方式:
csharp
public Point Move(int dx, int dy) => new Point(x + dx, y + dy);
public void Print() => Console.WriteLine(First + " " + Last);
// Works with operators, properties, and indexers too.
public static Complex operator +(Complex a, Complex b) => a.Add(b);
public string Name => First + " " + Last;
public Customer this[long id] => store.LookupCustomer(id);
如果该方法返回 void
或是异步方法,则该方法的主体必须是语句表达式(与 lambda 相同)。 对于属性和索引器,两者必须是只读的,并且不使用 get
访问器关键字。
迭代器
迭代器对集合执行自定义迭代,如列表或数组。 迭代器使用 yield return 语句返回元素,每次返回一个。 到达 yield return
语句后,会记住当前位置,以便调用方可以请求序列中的下一个元素。
迭代器的返回类型可以是 IEnumerable、IEnumerable、IAsyncEnumerable、IEnumerator 或 IEnumerator。
有关更多信息,请参见 迭代器。
面相对象(OOP)
构造函数概述
每当创建类或结构的实例时,将会调用其构造函数。 类或结构可能具有采用不同参数的多个构造函数。 使用构造函数,程序员能够设置默认值、限制实例化,并编写灵活易读的代码。 有关详细信息和示例,请参阅实例构造函数和使用构造函数。
有多个操作在初始化新实例时进行。 这些操作按以下顺序执行:
- 实例字段设置为 0。 这通常由运行时来完成。
- 字段初始值设定项运行。 派生程度最高类型的字段初始值设定项运行。
- 基类型字段初始值设定项运行。 以直接基开头从每个基类型到 System.Object 的字段初始值设定项。
- 基实例构造函数运行。 以 Object.Object 开头从每个基类到直接基类的任何实例构造函数。
- 实例构造函数运行。 该类型的实例构造函数运行。
- 对象初始值设定项运行。 如果表达式包含任何对象初始值设定项,后者会在实例构造函数运行后运行。 对象初始值设定项按文本顺序运行。
初始化新实例时,将执行上述操作。 如果 struct
的新实例设置为其 default
值,则所有实例字段都设置为 0。
如果静态构造函数尚未运行,静态构造函数会在任何实例构造函数操作执行之前运行。
构造函数语法
构造函数是一种方法,其名称与其类型的名称相同。 其方法签名仅包含可选访问修饰符、方法名称和其参数列表;它不包含返回类型。 以下示例演示一个名为 Person
的类的构造函数。
c#
public class Person
{
private string last;
private string first;
public Person(string lastName, string firstName)
{
last = lastName;
first = firstName;
}
// Remaining implementation of Person class.
}
如果某个构造函数可以作为单个语句实现,则可以使用表达式主体定义。 以下示例定义 Location
类,其构造函数具有一个名为"name"的字符串参数。 表达式主体定义给 locationName
字段分配参数。
c#
public class Location
{
private string locationName;
public Location(string name) => Name = name;
public string Name
{
get => locationName;
set => locationName = value;
}
}
静态构造函数
前面的示例具有所有已展示的实例构造函数,这些构造函数创建一个新对象。 类或结构也可以具有静态构造函数,该静态构造函数初始化类型的静态成员。 静态构造函数是无参数构造函数。 如果未提供静态构造函数来初始化静态字段,C# 编译器会将静态字段初始化为其默认值,如 C# 类型的默认值中所列。
以下示例使用静态构造函数来初始化静态字段。
c#
public class Adult : Person
{
private static int minimumAge;
public Adult(string lastName, string firstName) : base(lastName, firstName)
{ }
static Adult()
{
minimumAge = 18;
}
// Remaining implementation of Adult class.
}
也可以通过表达式主体定义来定义静态构造函数,如以下示例所示。
c#
public class Child : Person
{
private static int maximumAge;
public Child(string lastName, string firstName) : base(lastName, firstName)
{ }
static Child() => maximumAge = 18;
// Remaining implementation of Child class.
}
有关详细信息和示例,请参阅静态构造函数。
使用构造函数
实例化类或结构时,将会调用其构造函数。 构造函数与该类或结构具有相同名称,并且通常初始化新对象的数据成员。
在下面的示例中,通过使用简单构造函数定义了一个名为 Taxi
的类。 然后使用 new 运算符对该类进行实例化。 在为新对象分配内存之后,new
运算符立即调用 Taxi
构造函数。
csharp
public class Taxi
{
public bool IsInitialized;
public Taxi()
{
IsInitialized = true;
}
}
class TestTaxi
{
static void Main()
{
Taxi t = new Taxi();
Console.WriteLine(t.IsInitialized);
}
}
不带任何参数的构造函数称为"无参数构造函数"。 每当使用 new
运算符实例化对象且不为 new
提供任何参数时,会调用无参数构造函数。 C# 12 引入了主构造函数。 主构造函数指定为初始化新对象而必须提供的参数。 有关详细信息,请参阅实例构造函数。
除非类是静态的,否则 C# 编译器将为无构造函数的类提供一个公共的无参数构造函数,以便该类可以实例化。 有关详细信息,请参阅静态类和静态类成员。
通过将构造函数设置为私有构造函数,可以阻止类被实例化,如下所示:
csharp
class NLog
{
// Private Constructor:
private NLog() { }
public static double e = Math.E; //2.71828...
}
有关详细信息,请参阅私有构造函数。
结构类型的构造函数类似于类构造函数。 使用 new
实例化结构类型时,将调用构造函数。 将 struct
设置为其 default
值时,运行时会将结构中的所有内存初始化为 0。 在 C# 10 之前,structs
不能包含显式无参数构造函数,因为编译器会自动提供一个。 有关详细信息,请参阅结构类型一文的结构初始化和默认值部分。
以下代码使用 Int32 的无参数构造函数,因此可确保整数已初始化:
csharp
int i = new int();
Console.WriteLine(i);
但是,下面的代码会导致编译器错误,因为它不使用 new
,而且尝试使用尚未初始化的对象:
csharp
int i;
Console.WriteLine(i);
或者,可将基于 structs
的对象(包括所有内置数值类型)初始化或赋值后使用,如下面的示例所示:
csharp
int a = 44; // Initialize the value type...
int b;
b = 33; // Or assign it before using it.
Console.WriteLine("{0}, {1}", a, b);
类和结构都可以定义采用参数的构造函数,包括主构造函数。 必须通过 new
语句或 base 语句调用带参数的构造函数。 类和结构还可以定义多个构造函数,并且二者均无需定义无参数构造函数。 例如:
csharp
public class Employee
{
public int Salary;
public Employee() { }
public Employee(int annualSalary)
{
Salary = annualSalary;
}
public Employee(int weeklySalary, int numberOfWeeks)
{
Salary = weeklySalary * numberOfWeeks;
}
}
可使用下面任一语句创建此类:
csharp
Employee e1 = new Employee(30000);
Employee e2 = new Employee(500, 52);
构造函数可以使用 base
关键字调用基类的构造函数。 例如:
csharp
public class Manager : Employee
{
public Manager(int annualSalary)
: base(annualSalary)
{
//Add further instructions here.
}
}
在此示例中,在执行构造函数块之前调用基类的构造函数。 base
关键字可带参数使用,也可不带参数使用。 构造函数的任何参数都可用作 base
的参数,或用作表达式的一部分。 有关详细信息,请参阅 base。
在派生类中,如果不使用 base
关键字来显式调用基类构造函数,则将隐式调用无参数构造函数(若有)。 下面的构造函数声明等效:
csharp
public Manager(int initialData)
{
//Add further instructions here.
}
csharp
public Manager(int initialData)
: base()
{
//Add further instructions here.
}
如果基类没有提供无参数构造函数,派生类必须使用 base
显式调用基类构造函数。
构造函数可以使用 this 关键字调用同一对象中的另一构造函数。 和 base
一样,this
可带参数使用也可不带参数使用,构造函数中的任何参数都可用作 this
的参数,或者用作表达式的一部分。 例如,可以使用 this
重写前一示例中的第二个构造函数:
csharp
public Employee(int weeklySalary, int numberOfWeeks)
: this(weeklySalary * numberOfWeeks)
{
}
上一示例中使用 this
关键字会导致此构造函数被调用:
csharp
public Employee(int annualSalary)
{
Salary = annualSalary;
}
可以将构造函数标记为public、private、protected、internal、protected internal 或 private protected。 这些访问修饰符定义类的用户构造该类的方式。 有关详细信息,请参阅访问修饰符。
可使用 static 关键字将构造函数声明为静态构造函数。 在访问任何静态字段之前,都将自动调用静态构造函数,它们用于初始化静态类成员。 有关详细信息,请参阅静态构造函数。
实例构造函数
声明一个实例构造函数,以指定在使用 new
表达式创建某个类型的新实例时所执行的代码。 要初始化静态类或非静态类中的静态变量,可以定义静态构造函数。
如以下示例所示,可以在一种类型中声明多个实例构造函数:
csharp
class Coords
{
public Coords()
: this(0, 0)
{ }
public Coords(int x, int y)
{
X = x;
Y = y;
}
public int X { get; set; }
public int Y { get; set; }
public override string ToString() => $"({X},{Y})";
}
class Example
{
static void Main()
{
var p1 = new Coords();
Console.WriteLine($"Coords #1 at {p1}");
// Output: Coords #1 at (0,0)
var p2 = new Coords(5, 3);
Console.WriteLine($"Coords #2 at {p2}");
// Output: Coords #2 at (5,3)
}
}
在上个示例中,第一个无参数构造函数调用两个参数都等于 0
的第二个构造函数。 要执行此操作,请使用 this
关键字。
在派生类中声明实例构造函数时,可以调用基类的构造函数。 为此,请使用 base
关键字,如以下示例所示:
csharp
abstract class Shape
{
public const double pi = Math.PI;
protected double x, y;
public Shape(double x, double y)
{
this.x = x;
this.y = y;
}
public abstract double Area();
}
class Circle : Shape
{
public Circle(double radius)
: base(radius, 0)
{ }
public override double Area() => pi * x * x;
}
class Cylinder : Circle
{
public Cylinder(double radius, double height)
: base(radius)
{
y = height;
}
public override double Area() => (2 * base.Area()) + (2 * pi * x * y);
}
class Example
{
static void Main()
{
double radius = 2.5;
double height = 3.0;
var ring = new Circle(radius);
Console.WriteLine($"Area of the circle = {ring.Area():F2}");
// Output: Area of the circle = 19.63
var tube = new Cylinder(radius, height);
Console.WriteLine($"Area of the cylinder = {tube.Area():F2}");
// Output: Area of the cylinder = 86.39
}
}
无参数构造函数
如果某个类没有显式实例构造函数,C# 将提供可用于实例化该类实例的无参数构造函数,如以下示例所示:
csharp
public class Person
{
public int age;
public string name = "unknown";
}
class Example
{
static void Main()
{
var person = new Person();
Console.WriteLine($"Name: {person.name}, Age: {person.age}");
// Output: Name: unknown, Age: 0
}
}
该构造函数根据相应的初始值设定项初始化实例字段和属性。 如果字段或属性没有初始值设定项,其值将设置为字段或属性类型的默认值。 如果在某个类中声明至少一个实例构造函数,则 C# 不提供无参数构造函数。
structure 类型始终提供无参数构造函数: 无参数构造函数是可生成某种类型的默认值的隐式无参数构造函数或显式声明的无参数构造函数。 有关详细信息,请参阅结构类型一文的结构初始化和默认值部分。
主构造函数
从 C# 12 开始,可以在类和结构中声明主构造函数。 将任何参数放在类型名称后面的括号中:
csharp
public class NamedItem(string name)
{
public string Name => name;
}
主构造函数的参数位于声明类型的整个主体中。 它们可以初始化属性或字段。 它们可用作方法或局部函数中的变量。 它们可以传递给基本构造函数。
主构造函数指示这些参数对于类型的任何实例是必需的。 任何显式编写的构造函数都必须使用 this(...)
初始化表达式语法来调用主构造函数。 这可确保主构造函数参数绝对由所有构造函数分配。 对于任何 class
类型(包括 record class
类型),当主构造函数存在时,不会发出隐式无参数构造函数。 对于任何 struct
类型(包括 record struct
类型),始终发出隐式无参数构造函数,并始终将所有字段(包括主构造函数参数)初始化为 0 位模式。 如果编写显式无参数构造函数,则必须调用主构造函数。 在这种情况下,可以为主构造函数参数指定不同的值。 以下代码演示主构造函数的示例。
csharp
// name isn't captured in Widget.
// width, height, and depth are captured as private fields
public class Widget(string name, int width, int height, int depth) : NamedItem(name)
{
public Widget() : this("N/A", 1,1,1) {} // unnamed unit cube
public int WidthInCM => width;
public int HeightInCM => height;
public int DepthInCM => depth;
public int Volume => width * height * depth;
}
可以通过在属性上指定 method:
目标,可以将属性添加到合成的主要构造函数方法:
csharp
[method: MyAttribute]
public class TaggedWidget(string name)
{
// details elided
}
如果未指定 method
目标,则属性将放置在类上而不是方法上。
在 class
和 struct
类型中,主构造函数参数在类型主体中的任意位置可用。 它们可用作成员字段。 使用主构造函数参数时,编译器使用编译器生成的名称捕获私有字段中的构造函数参数。 如果类型主体中未使用主构造函数参数,则不会捕获私有字段。 该规则可防止意外分配传递给基构造函数的主构造函数参数的两个副本。
如果该类型包含 record
修饰符,则编译器将合成一个与主构造函数参数同名的公共属性。 对于 record class
类型,如果主构造函数参数使用与基主构造函数相同的名称,则该属性是基 record class
类型的公共属性。 它在派生的 record class
类型中不会重复。 不会为非 record
类型生成这些属性。
私有构造函数
私有构造函数是一种特殊的实例构造函数。 它通常用于只包含静态成员的类中。 如果类具有一个或多个私有构造函数而没有公共构造函数,则其他类(除嵌套类外)无法创建该类的实例。 例如:
csharp
class NLog
{
// Private Constructor:
private NLog() { }
public static double e = Math.E; //2.71828...
}
声明空构造函数可阻止自动生成无参数构造函数。 请注意,如果不对构造函数使用访问修饰符,则在默认情况下它仍为私有构造函数。 但是,通常会显式地使用 private 修饰符来清楚地表明该类不能被实例化。
当没有实例字段或实例方法(例如 Math 类)时或者当调用方法以获得类的实例时,私有构造函数可用于阻止创建类的实例。 如果类中的所有方法都是静态的,可考虑使整个类成为静态的。 有关详细信息,请参阅静态类和静态类成员。
示例
下面是使用私有构造函数的类的示例。
csharp
public class Counter
{
private Counter() { }
public static int currentCount;
public static int IncrementCount()
{
return ++currentCount;
}
}
class TestCounter
{
static void Main()
{
// If you uncomment the following statement, it will generate
// an error because the constructor is inaccessible:
// Counter aCounter = new Counter(); // Error
Counter.currentCount = 100;
Counter.IncrementCount();
Console.WriteLine("New count: {0}", Counter.currentCount);
// Keep the console window open in debug mode.
Console.WriteLine("Press any key to exit.");
Console.ReadKey();
}
}
// Output: New count: 101
请注意,如果取消注释该示例中的以下语句,它将生成一个错误,因为该构造函数受其保护级别的限制而不可访问:
csharp
// Counter aCounter = new Counter(); // Error
静态构造函数
静态构造函数用于初始化任何静态数据,或执行仅需执行一次的特定操作。 将在创建第一个实例或引用任何静态成员之前自动调用静态构造函数。 静态构造函数最多调用一次。
csharp
class SimpleClass
{
// Static variable that must be initialized at run time.
static readonly long baseline;
// Static constructor is called at most one time, before any
// instance constructor is invoked or member is accessed.
static SimpleClass()
{
baseline = DateTime.Now.Ticks;
}
}
有多个操作在静态初始化时执行。 这些操作按以下顺序执行:
- 静态字段设置为 0。 这通常由运行时来完成。
- 静态字段初始值设定项运行。 派生程度最高类型的静态字段初始值设定项运行。
- 基类型静态字段初始值设定项运行。 以直接基开头从每个基类型到 System.Object 的静态字段初始值设定项。
- 基本静态构造函数运行。 以 Object.Object 开头从每个基类到直接基类的任何静态构造函数。
- 静态构造函数运行。 该类型的静态构造函数运行。
模块初始化表达式可以替代静态构造函数。 有关详细信息,请参阅模块初始化表达式的规范。
备注
静态构造函数具有以下属性:
- 静态构造函数不使用访问修饰符或不具有参数。
- 类或结构只能有一个静态构造函数。
- 静态构造函数不能继承或重载。
- 静态构造函数不能直接调用,并且仅应由公共语言运行时 (CLR) 调用。 可以自动调用它们。
- 用户无法控制在程序中执行静态构造函数的时间。
- 自动调用静态构造函数。 它在创建第一个实例或引用该类(不是其基类)中声明的任何静态成员之前初始化类。 静态构造函数在实例构造函数之前运行。 如果静态构造函数类中存在静态字段变量初始值设定项,它们将以在类声明中显示的文本顺序执行。 初始值设定项紧接着执行静态构造函数之前运行。
- 如果未提供静态构造函数来初始化静态字段,会将所有静态字段初始化为其默认值,如 C# 类型的默认值中所列。
- 如果静态构造函数引发异常,运行时将不会再次调用该函数,并且类型在应用程序域的生存期内将保持未初始化。 大多数情况下,当静态构造函数无法实例化一个类型时,或者当静态构造函数中发生未经处理的异常时,将引发 TypeInitializationException 异常。 对于未在源代码中显式定义的静态构造函数,故障排除可能需要检查中间语言 (IL) 代码。
- 静态构造函数的存在将防止添加 BeforeFieldInit 类型属性。 这将限制运行时优化。
- 声明为
static readonly
的字段可能仅被分配为其声明的一部分或在静态构造函数中。 如果不需要显式静态构造函数,请在声明时初始化静态字段,而不是通过静态构造函数,以实现更好的运行时优化。 - 运行时在单个应用程序域中多次调用静态构造函数。 该调用是基于特定类型的类在锁定区域中进行的。 静态构造函数的主体中不需要其他锁定机制。 若要避免死锁的风险,请勿阻止静态构造函数和初始值设定项中的当前线程。 例如,不要等待任务、线程、等待句柄或事件,不要获取锁定,也不要执行阻止并行操作,如并行循环、
Parallel.Invoke
和并行 LINQ 查询。
备注
尽管不可直接访问,但应记录显式静态构造函数的存在,以帮助故障排除初始化异常。
用法
- 静态构造函数的一种典型用法是在类使用日志文件且将构造函数用于将条目写入到此文件中时使用。
- 静态构造函数对于创建非托管代码的包装类也非常有用,这种情况下构造函数可调用
LoadLibrary
方法。 - 也可在静态构造函数中轻松地对无法在编译时通过类型参数约束检查的类型参数强制执行运行时检查。
示例
在此示例中,类 Bus
具有静态构造函数。 创建 Bus
的第一个实例 (bus1
) 时,将调用该静态构造函数,以便初始化类。 示例输出验证即使创建了两个 Bus
的实例,静态构造函数也仅运行一次,并且在实例构造函数运行前运行。
csharp
public class Bus
{
// Static variable used by all Bus instances.
// Represents the time the first bus of the day starts its route.
protected static readonly DateTime globalStartTime;
// Property for the number of each bus.
protected int RouteNumber { get; set; }
// Static constructor to initialize the static variable.
// It is invoked before the first instance constructor is run.
static Bus()
{
globalStartTime = DateTime.Now;
// The following statement produces the first line of output,
// and the line occurs only once.
Console.WriteLine("Static constructor sets global start time to {0}",
globalStartTime.ToLongTimeString());
}
// Instance constructor.
public Bus(int routeNum)
{
RouteNumber = routeNum;
Console.WriteLine("Bus #{0} is created.", RouteNumber);
}
// Instance method.
public void Drive()
{
TimeSpan elapsedTime = DateTime.Now - globalStartTime;
// For demonstration purposes we treat milliseconds as minutes to simulate
// actual bus times. Do not do this in your actual bus schedule program!
Console.WriteLine("{0} is starting its route {1:N2} minutes after global start time {2}.",
this.RouteNumber,
elapsedTime.Milliseconds,
globalStartTime.ToShortTimeString());
}
}
class TestBus
{
static void Main()
{
// The creation of this instance activates the static constructor.
Bus bus1 = new Bus(71);
// Create a second bus.
Bus bus2 = new Bus(72);
// Send bus1 on its way.
bus1.Drive();
// Wait for bus2 to warm up.
System.Threading.Thread.Sleep(25);
// Send bus2 on its way.
bus2.Drive();
// Keep the console window open in debug mode.
Console.WriteLine("Press any key to exit.");
Console.ReadKey();
}
}
/* Sample output:
Static constructor sets global start time to 3:57:08 PM.
Bus #71 is created.
Bus #72 is created.
71 is starting its route 6.00 minutes after global start time 3:57 PM.
72 is starting its route 31.00 minutes after global start time 3:57 PM.
*/
如何编写复制构造函数
C # 记录为对象提供复制构造函数,但对于类,你必须自行编写。
重要
编写适用于类层次结构中所有派生类型的复制构造函数可能很困难。 如果类不是
sealed
,则强烈建议考虑创建record class
类型的层次结构,以使用编译器合成的复制构造函数。
示例
在下面的示例中,Person
类定义一个复制构造函数,该函数使用 Person
的实例作为其参数。 该参数的属性值分配给 Person
的新实例的属性。 该代码包含一个备用复制构造函数,该函数发送要复制到该类的实例构造函数的实例的 Name
和 Age
属性。 Person
类为 sealed
,因此无法通过仅复制基类来声明可能会引发错误的派生类型。
csharp
public sealed class Person
{
// Copy constructor.
public Person(Person previousPerson)
{
Name = previousPerson.Name;
Age = previousPerson.Age;
}
Alternate copy constructor calls the instance constructor.
//public Person(Person previousPerson)
// : this(previousPerson.Name, previousPerson.Age)
//{
//}
// Instance constructor.
public Person(string name, int age)
{
Name = name;
Age = age;
}
public int Age { get; set; }
public string Name { get; set; }
public string Details()
{
return Name + " is " + Age.ToString();
}
}
class TestPerson
{
static void Main()
{
// Create a Person object by using the instance constructor.
Person person1 = new Person("George", 40);
// Create another Person object, copying person1.
Person person2 = new Person(person1);
// Change each person's age.
person1.Age = 39;
person2.Age = 41;
// Change person2's name.
person2.Name = "Charles";
// Show details to verify that the name and age fields are distinct.
Console.WriteLine(person1.Details());
Console.WriteLine(person2.Details());
// Keep the console window open in debug mode.
Console.WriteLine("Press any key to exit.");
Console.ReadKey();
}
}
// Output:
// George is 39
// Charles is 41
访问修饰符
所有类型和类型成员都具有可访问性级别。 该级别可以控制是否可以从你的程序集或其他程序集中的其他代码中使用它们。 程序集是通过在单个编译中编译一个或多个 .cs 文件而创建的 .dll 或 .exe。 可以使用以下访问修饰符在进行声明时指定类型或成员的可访问性:
- public:同一程序集中的任何其他代码或引用该程序集的其他程序集都可以访问该类型或成员。 某一类型的公共成员的可访问性水平由该类型本身的可访问性级别控制。
- private:只有同一
class
或struct
中的代码可以访问该类型或成员。 - protected:只有同一
class
或者从该class
派生的class
中的代码可以访问该类型或成员。 - internal:同一程序集中的任何代码都可以访问该类型或成员,但其他程序集中的代码不可以。 换句话说,
internal
类型或成员可以从属于同一编译的代码中访问。 - protected internal:该类型或成员可由对其进行声明的程序集或另一程序集中的派生
class
中的任何代码访问。 - private protected:该类型或成员可以通过从
class
派生的类型访问,这些类型在其包含程序集中进行声明。
摘要表
调用方的位置 | public |
protected internal |
protected |
internal |
private protected |
private |
---|---|---|---|---|---|---|
在类内 | ✔️️ | ✔ | ✔ | ✔ | ✔ | ✔ |
派生类(相同程序集) | ✔ | ✔ | ✔ | ✔ | ✔ | ❌ |
非派生类(相同程序集) | ✔ | ✔ | ❌ | ✔ | ❌ | ❌ |
派生类(不同程序集) | ✔ | ✔ | ✔ | ❌ | ❌ | ❌ |
非派生类(不同程序集) | ✔ | ❌ | ❌ | ❌ | ❌ | ❌ |
下面的示例演示如何在类型和成员上指定访问修饰符:
c#
public class Bicycle
{
public void Pedal() { }
}
不是所有访问修饰符都可以在所有上下文中由所有类型或成员使用。 在某些情况下,类型成员的可访问性受到其包含类型的可访问性的限制。
类、记录和结构可访问性
直接在命名空间中声明的类、记录和结构(即,没有嵌套在其他类或结构中的类、记录和结构)可以为 public
或 internal
。 如果未指定任何访问修饰符,则默认设置为 internal
。
结构成员(包括嵌套的类和结构)可以声明为 public
、internal
或 private
。 类成员(包括嵌套的类和结构)可以声明为 public
、protected internal
、protected
、internal
、private protected
或 private
。 默认情况下,类成员和结构成员(包括嵌套的类和结构)的访问级别为 private
。 不能从包含类型的外部访问私有嵌套类型。
派生类和派生记录不能具有高于其基类型的可访问性。 不能声明派生自内部类 A
的公共类 B
。 如果允许这样,则它将具有使 A
公开的效果,因为可从派生类访问 A
的所有 protected
或 internal
成员。
可以通过使用 InternalsVisibleToAttribute
启用特定的其他程序集访问内部类型。 有关详细信息,请参阅友元程序集。
类、记录和结构成员可访问性
可以使用六种访问类型中的任意一种声明类和记录成员(包括嵌套的类、记录和结构)。 结构成员无法声明为 protected
、protected internal
或 private protected
,因为结构不支持继承。
通常情况下,成员的可访问性不大于包含该成员的类型的可访问性。 但是,如果内部类的 public
成员实现了接口方法或替代了在公共基类中定义的虚拟方法,则可从该程序集的外部访问该成员。
为字段、属性或事件的任何成员的类型必须至少与该成员本身具有相同的可访问性。 同样,任何方法、索引器或委托的返回类型和参数类型必须至少与该成员本身具有相同的可访问性。 例如,除非 C
也是 public
,否则不能具有返回类 C
的 public
方法 M
。 同样,如果 A
声明为 private
,则不能具有类型 A
的 protected
属性。
用户定义的运算符始终必须声明为 public
和 static
。 有关详细信息,请参阅运算符重载。
终结器不能具有可访问性修饰符。
若要设置 class
、record
或 struct
成员的访问级别,请向成员声明添加适当的关键字,如以下示例中所示。
csharp
// public class:
public class Tricycle
{
// protected method:
protected void Pedal() { }
// private field:
private int _wheels = 3;
// protected internal property:
protected internal int Wheels
{
get { return _wheels; }
}
}
其他类型
在命名空间内直接声明的接口可以声明为 public
或 internal
,就像类和结构一样,接口默认设置为 internal
访问级别。 接口成员默认为 public
,因为接口的用途是启用其他类型以访问类或结构。 接口成员声明可以包含任何访问修饰符。 这最适用于静态方法,以提供类的所有实现器需要的常见实现。
枚举成员始终为 public
,并且不能应用任何访问修饰符。
委托类似于类和结构。 默认情况下,当在命名空间内直接声明它们时,它们具有 internal
访问级别,当将它们嵌套在命名空间内时,它们具有 private
访问级别。
默认访问摘要表
类型 | 默认访问权限 |
---|---|
class |
internal |
struct |
internal |
interface |
internal |
record |
internal |
enum |
internal |
interface 成员 |
public |
匿名类型 | internal |
类、记录和结构成员 | private |
有关详细信息,请参阅辅助功能级别页。
属性是一种成员,它提供灵活的机制来读取、写入或计算私有字段的值。 属性可用作公共数据成员,但它们是称为"访问器"的特殊方法。 此功能使得可以轻松访问数据,还有助于提高方法的安全性和灵活性。
属性
属性概述
- 属性允许类公开获取和设置值的公共方法,而隐藏实现或验证代码。
- get 属性访问器用于返回属性值,而 set 属性访问器用于分配新值。 init 属性访问器仅用于在对象构造过程中分配新值。 这些访问器可以具有不同的访问级别。 有关详细信息,请参阅限制访问器可访问性。
- value 关键字用于定义由
set
或init
访问器分配的值。 - 属性可以是读-写 属性(既有
get
访问器又有set
访问器)、只读 属性(有get
访问器,但没有set
访问器)或只写 访问器(有set
访问器,但没有get
访问器)。 只写属性很少出现,常用于限制对敏感数据的访问。 - 不需要自定义访问器代码的简单属性可以作为表达式主体定义或自动实现的属性来实现。
具有支持字段的属性
有一个实现属性的基本模式,该模式使用私有支持字段来设置和检索属性值。 get
访问器返回私有字段的值,set
访问器在向私有字段赋值之前可能会执行一些数据验证。 这两个访问器还可以在存储或返回数据之前对其执行某些转换或计算。
下面的示例阐释了此模式。 在此示例中,TimePeriod
类表示时间间隔。 在内部,该类将时间间隔以秒为单位存储在名为 _seconds
的私有字段中。 名为 Hours
的读-写属性允许客户以小时为单位指定时间间隔。 get
和 set
访问器都会执行小时与秒之间的必要转换。 此外,set
访问器还会验证数据,如果小时数无效,则引发 ArgumentOutOfRangeException。
csharp
public class TimePeriod
{
private double _seconds;
public double Hours
{
get { return _seconds / 3600; }
set
{
if (value < 0 || value > 24)
throw new ArgumentOutOfRangeException(nameof(value),
"The valid range is between 0 and 24.");
_seconds = value * 3600;
}
}
}
可以访问属性以获取和设置值,如以下示例所示:
csharp
TimePeriod t = new TimePeriod();
// The property assignment causes the 'set' accessor to be called.
t.Hours = 24;
// Retrieving the property causes the 'get' accessor to be called.
Console.WriteLine($"Time in hours: {t.Hours}");
// The example displays the following output:
// Time in hours: 24
表达式主体定义
属性访问器通常由单行语句组成,这些语句只分配或只返回表达式的结果。 可以将这些属性作为 expression-bodied 成员来实现。 =>
符号后跟用于为属性赋值或从属性中检索值的表达式,即组成了表达式主体定义。
只读属性可以将 get
访问器作为 expression-bodied 成员实现。 在这种情况下,既不使用 get
访问器关键字,也不使用 return
关键字。 下面的示例将只读 Name
属性作为 expression-bodied 成员实现。
csharp
public class Person
{
private string _firstName;
private string _lastName;
public Person(string first, string last)
{
_firstName = first;
_lastName = last;
}
public string Name => $"{_firstName} {_lastName}";
}
get
和 set
访问器都可以作为 expression-bodied 成员实现。 在这种情况下,必须使用 get
和 set
关键字。 下面的示例阐释如何为这两个访问器使用表达式主体定义。 return
关键字不与 get
访问器一起使用。
csharp
public class SaleItem
{
string _name;
decimal _cost;
public SaleItem(string name, decimal cost)
{
_name = name;
_cost = cost;
}
public string Name
{
get => _name;
set => _name = value;
}
public decimal Price
{
get => _cost;
set => _cost = value;
}
}
自动实现的属性
在某些情况下,属性 get
和 set
访问器仅向支持字段赋值或仅从其中检索值,而不包括任何附加逻辑。 通过使用自动实现的属性,既能简化代码,还能让 C# 编译器透明地提供支持字段。
如果属性具有 get
和 set
(或 get
和 init
)访问器,则必须自动实现这两个访问器。 自动实现的属性通过以下方式定义:使用 get
和 set
关键字,但不提供任何实现。 下面的示例与上一个示例基本相同,只不过 Name
和 Price
是自动实现的属性。 该示例还删除了参数化构造函数,以便通过调用无参数构造函数和对象初始值设定项立即初始化 SaleItem
对象。
csharp
public class SaleItem
{
public string Name
{ get; set; }
public decimal Price
{ get; set; }
}
自动实现的属性可以为 get
和 set
访问器声明不同的可访问性。 通常声明一个公共 get
访问器和一个专用 set
访问器。 可以在有关限制访问器可访问性的文章中了解详细信息。
必需的属性
从 C# 11 开始,可以添加 required
成员以强制客户端代码初始化任何属性或字段:
csharp
public class SaleItem
{
public required string Name
{ get; set; }
public required decimal Price
{ get; set; }
}
若要创建 SaleItem
,必须使用对象初始值设定项设置 Name
和 Price
属性,如以下代码所示:
csharp
var item = new SaleItem { Name = "Shoes", Price = 19.95m };
Console.WriteLine($"{item.Name}: sells for {item.Price:C2}");
常量
常量是不可变的值,在编译时是已知的,在程序的生命周期内不会改变。 常量使用 const 修饰符声明。 只有 C# 内置类型可声明为 const
。 除 String 以外的引用类型常量只能使用 null 值进行初始化。 用户定义的类型(包括类、结构和数组)不能为 const
。 使用 readonly 修饰符创建在运行时一次性(例如在构造函数中)初始化的类、结构或数组,此后不能更改。
C# 不支持 const
方法、属性或事件。
枚举类型使你能够为整数内置类型定义命名常量(例如 int
、uint
、long
等)。 有关详细信息,请参阅枚举。
常量在声明时必须初始化。 例如:
csharp
class Calendar1
{
public const int Months = 12;
}
在此示例中,常量 Months
始终为 12,即使类本身也无法更改它。 实际上,当编译器遇到 C# 源代码中的常量标识符(例如,Months
)时,它直接将文本值替换到它生成的中间语言 (IL) 代码中。 因为运行时没有与常量相关联的变量地址,所以 const
字段不能通过引用传递,并且不能在表达式中显示为左值。
备注
引用其他代码(如 DLL)中定义的常量值时要格外小心。 如果新版本的 DLL 定义了新的常量值,则程序仍将保留旧的文本值,直到根据新版本重新编译。
可以同时声明多个同一类型的常量,例如:
csharp
class Calendar2
{
public const int Months = 12, Weeks = 52, Days = 365;
}
如果不创建循环引用,则用于初始化常量的表达式可以引用另一个常量。 例如:
csharp
class Calendar3
{
public const int Months = 12;
public const int Weeks = 52;
public const int Days = 365;
public const double DaysPerWeek = (double) Days / (double) Weeks;
public const double DaysPerMonth = (double) Days / (double) Months;
}
可以将常量标记为public、private、protected、internal、protected internal 或 private protected。 这些访问修饰符定义该类的用户访问该常量的方式。 有关详细信息,请参阅访问修饰符。
常量是作为静态字段访问的,因为常量的值对于该类型的所有实例都是相同的。 不使用 static
关键字来声明这些常量。 不在定义常量的类中的表达式必须使用类名、句点和常量名称来访问该常量。 例如:
csharp
int birthstones = Calendar.Months;
readonly
readonly
关键字是一个可在五个上下文中使用的修饰符:
-
在字段声明中,
readonly
指示只能在声明期间或在同一个类的构造函数中向字段赋值。 可以在字段声明和构造函数中多次分配和重新分配只读字段。构造函数退出后,不能分配
readonly
字段。 此规则对于值类型和引用类型具有不同的含义:- 由于值类型直接包含数据,因此属于
readonly
值类型的字段不可变。 - 由于引用类型包含对其数据的引用,因此属于
readonly
引用类型的字段必须始终引用同一对象。 该对象可能是可变的。readonly
修饰符可防止将字段值替换为引用类型的其他实例。 但是,修饰符不会阻止通过只读字段修改字段的实例数据。
警告
包含属于可变引用类型的外部可见只读字段的外部可见类型可能存在安全漏洞,可能会触发警告 CA2104:"不要声明只读可变引用类型。"
- 由于值类型直接包含数据,因此属于
-
在
readonly struct
类型定义中,readonly
指示结构类型是不可变的。 有关详细信息,请参阅结构类型一文中的readonly
结构一节。 -
在结构类型内的实例成员声明中,
readonly
指示实例成员不修改结构的状态。 有关详细信息,请参阅结构类型一文中的readonly
实例成员部分。 -
在
ref readonly
方法返回中,readonly
修饰符指示该方法返回一个引用,且不允许向该引用写入内容。- 将
ref readonly
参数声明到某个方法。
- 将
Readonly 字段示例
在此示例中,即使在类构造函数中给字段 year
赋了值,也无法在方法 ChangeYear
中更改其值:
csharp
class Age
{
private readonly int _year;
Age(int year)
{
_year = year;
}
void ChangeYear()
{
//_year = 1967; // Compile error if uncommented.
}
}
只能在下列上下文中对 readonly
字段进行赋值:
-
在声明中初始化变量时,例如:
csharppublic readonly int y = 5;
-
在包含实例字段声明的类的实例构造函数中。
-
在包含静态字段声明的类的静态构造函数中。
只有在这些构造函数上下文中,将 readonly
字段作为 out 或 ref 参数传递才有效。
备注
readonly
关键字不同于 const 关键字。const
字段只能在该字段的声明中初始化。 可以在字段声明和任何构造函数中多次分配readonly
字段。 因此,根据所使用的构造函数,readonly
字段可能具有不同的值。 另外,虽然const
字段是编译时常量,但readonly
字段可用于运行时常量,如下面的示例所示:
csharppublic static readonly uint timeStamp = (uint)DateTime.Now.Ticks;
csharp
public class SamplePoint
{
public int x;
// Initialize a readonly field
public readonly int y = 25;
public readonly int z;
public SamplePoint()
{
// Initialize a readonly instance field
z = 24;
}
public SamplePoint(int p1, int p2, int p3)
{
x = p1;
y = p2;
z = p3;
}
public static void Main()
{
SamplePoint p1 = new SamplePoint(11, 21, 32); // OK
Console.WriteLine($"p1: x={p1.x}, y={p1.y}, z={p1.z}");
SamplePoint p2 = new SamplePoint();
p2.x = 55; // OK
Console.WriteLine($"p2: x={p2.x}, y={p2.y}, z={p2.z}");
}
/*
Output:
p1: x=11, y=21, z=32
p2: x=55, y=25, z=24
*/
}
在前面的示例中,如果使用类似以下示例的语句:
csharp
p2.y = 66; // Error
收到编译器错误消息:
无法对只读的字段赋值(构造函数或变量初始值指定项中除外)
只读实例成员
还可以使用 readonly
修饰符来声明实例成员不会修改结构的状态。
csharp
public readonly double Sum()
{
return X + Y;
}
备注
对于读/写属性,可以将
readonly
修饰符添加到get
访问器。 某些get
访问器可以执行计算并缓存结果,而不是只返回专用字段的值。 将readonly
修饰符添加到get
访问器可确保get
访问器不会通过缓存任何结果来修改对象的内部状态。
可在结构类型一文中的 readonly
实例成员部分查看更多示例。
Ref readonly 返回示例
ref return
上的 readonly
修饰符指示返回的引用无法修改。 下面的示例返回了一个对来源的引用。 它使用 readonly
修饰符来指示调用方无法修改来源:
csharp
private static readonly SamplePoint s_origin = new SamplePoint(0, 0, 0);
public static ref readonly SamplePoint Origin => ref s_origin;
所返回的类型不需要为 readonly struct
。 ref
能返回的任何类型都能由 ref readonly
返回。
只读 Ref readonly return 示例
还可以将 ref readonly return
与 struct
类型上的 readonly
实例成员结合使用:
csharp
public struct ReadonlyRefReadonlyExample
{
private int _data;
public readonly ref readonly int ReadonlyRefReadonly(ref int reference)
{
// _data = 1; // Compile error if uncommented.
return ref reference;
}
}
该方法本质上返回一个 readonly
引用,以及状态为 readonly
(无法修改任何实例字段)的实例成员(在本例中为方法)。
索引和范围
对索引和范围的语言支持
索引和范围为访问序列中的单个元素或范围提供了简洁的语法。
此语言支持依赖于两个新类型和两个新运算符:
- System.Index 表示一个序列索引。
- 来自末尾运算符
^
的索引,指定一个索引与序列末尾相关。 - System.Range 表示序列的子范围。
- 范围运算符
..
,用于将范围的开头和末尾指定为其操作数。
让我们从索引规则开始。 请考虑数组 sequence
。 0
索引与 sequence[0]
相同。 ^0
索引与 sequence[sequence.Length]
相同。 表达式 sequence[^0]
会引发异常,就像 sequence[sequence.Length]
一样。 对于任何数字 n
,索引 ^n
与 sequence.Length - n
相同。
csharp
string[] words = [
// index from start index from end
"The", // 0 ^9
"quick", // 1 ^8
"brown", // 2 ^7
"fox", // 3 ^6
"jumps", // 4 ^5
"over", // 5 ^4
"the", // 6 ^3
"lazy", // 7 ^2
"dog" // 8 ^1
]; // 9 (or words.Length) ^0
可以使用 ^1
索引检索最后一个词。 在初始化下面添加以下代码:
csharp
Console.WriteLine($"The last word is {words[^1]}");
范围指定范围的开始和末尾。 包括此范围的开始,但不包括此范围的末尾,这表示此范围包含开始但不包含末尾 。 范围 [0..^0]
表示整个范围,就像 [0..sequence.Length]
表示整个范围。
以下代码创建了一个包含单词"quick"、"brown"和"fox"的子范围。 它包括 words[1]
到 words[3]
。 元素 words[4]
不在该范围内。
csharp
string[] quickBrownFox = words[1..4];
foreach (var word in quickBrownFox)
Console.Write($"< {word} >");
Console.WriteLine();
以下代码使用"lazy"和"dog"返回范围。 它包括 words[^2]
和 words[^1]
。 结束索引 words[^0]
不包括在内。 同样添加以下代码:
csharp
string[] lazyDog = words[^2..^0];
foreach (var word in lazyDog)
Console.Write($"< {word} >");
Console.WriteLine();
下面的示例为开始和/或结束创建了开放范围:
csharp
string[] allWords = words[..]; // contains "The" through "dog".
string[] firstPhrase = words[..4]; // contains "The" through "fox"
string[] lastPhrase = words[6..]; // contains "the", "lazy" and "dog"
foreach (var word in allWords)
Console.Write($"< {word} >");
Console.WriteLine();
foreach (var word in firstPhrase)
Console.Write($"< {word} >");
Console.WriteLine();
foreach (var word in lastPhrase)
Console.Write($"< {word} >");
Console.WriteLine();
还可以将范围或索引声明为变量。 然后可以在 [
和 ]
字符中使用该变量:
csharp
Index the = ^3;
Console.WriteLine(words[the]);
Range phrase = 1..4;
string[] text = words[phrase];
foreach (var word in text)
Console.Write($"< {word} >");
Console.WriteLine();
下面的示例展示了使用这些选项的多种原因。 请修改 x
、y
和 z
以尝试不同的组合。 在进行实验时,请使用 x
小于 y
且 y
小于 z
的有效组合值。 在新方法中添加以下代码。 尝试不同的组合:
csharp
int[] numbers = [..Enumerable.Range(0, 100)];
int x = 12;
int y = 25;
int z = 36;
Console.WriteLine($"{numbers[^x]} is the same as {numbers[numbers.Length - x]}");
Console.WriteLine($"{numbers[x..y].Length} is the same as {y - x}");
Console.WriteLine("numbers[x..y] and numbers[y..z] are consecutive and disjoint:");
Span<int> x_y = numbers[x..y];
Span<int> y_z = numbers[y..z];
Console.WriteLine($"\tnumbers[x..y] is {x_y[0]} through {x_y[^1]}, numbers[y..z] is {y_z[0]} through {y_z[^1]}");
Console.WriteLine("numbers[x..^x] removes x elements at each end:");
Span<int> x_x = numbers[x..^x];
Console.WriteLine($"\tnumbers[x..^x] starts with {x_x[0]} and ends with {x_x[^1]}");
Console.WriteLine("numbers[..x] means numbers[0..x] and numbers[x..] means numbers[x..^0]");
Span<int> start_x = numbers[..x];
Span<int> zero_x = numbers[0..x];
Console.WriteLine($"\t{start_x[0]}..{start_x[^1]} is the same as {zero_x[0]}..{zero_x[^1]}");
Span<int> z_end = numbers[z..];
Span<int> z_zero = numbers[z..^0];
Console.WriteLine($"\t{z_end[0]}..{z_end[^1]} is the same as {z_zero[0]}..{z_zero[^1]}");
不仅数组支持索引和范围。 还可以将索引和范围用于 string、Span 或 ReadOnlySpan。
隐式范围运算符表达式转换
使用范围运算符表达式语法时,编译器会将开始值和结束值隐式转换为 Index,并根据这些值创建新的 Range 实例。 以下代码显示了范围运算符表达式语法的隐式转换示例及其对应的显式替代方法:
csharp
Range implicitRange = 3..^5;
Range explicitRange = new(
start: new Index(value: 3, fromEnd: false),
end: new Index(value: 5, fromEnd: true));
if (implicitRange.Equals(explicitRange))
{
Console.WriteLine(
$"The implicit range '{implicitRange}' equals the explicit range '{explicitRange}'");
}
// Sample output:
// The implicit range '3..^5' equals the explicit range '3..^5'
重要
当值为负数时,从 Int32 隐式转换为 Index 会引发 ArgumentOutOfRangeException。 同样,当
value
参数为负时,Index
构造函数会引发ArgumentOutOfRangeException
。
索引和范围的类型支持
索引和范围提供清晰、简洁的语法来访问序列中的单个元素或元素的范围。 索引表达式通常返回序列元素的类型。 范围表达式通常返回与源序列相同的序列类型。
若任何类型提供带 Index 或 Range 参数的索引器,则该类型可分别显式支持索引或范围。 采用单个 Range 参数的索引器可能会返回不同的序列类型,如 System.Span。
重要
使用范围运算符的代码的性能取决于序列操作数的类型。
范围运算符的时间复杂度取决于序列类型。 例如,如果序列是
string
或数组,则结果是输入中指定部分的副本,因此,时间复杂度为 O(N)(其中 N 是范围的长度)。 另一方面,如果它是 System.Span 或 System.Memory,则结果引用相同的后备存储,这意味着没有副本且操作为 O(1)。除了时间复杂度外,这还会产生额外的分配和副本,从而影响性能。 在性能敏感的代码中,考虑使用
Span<T>
或Memory<T>
作为序列类型,因为不会为其分配范围运算符。
若类型包含名称为 Length
或 Count
的属性,属性有可访问的 Getter 并且其返回类型为 int
,则此类型为可计数类型。 不显式支持索引或范围的可计数类型可能为它们提供隐式支持。 有关详细信息,请参阅功能建议说明的隐式索引支持和隐式范围支持部分。 使用隐式范围支持的范围将返回与源序列相同的序列类型。
例如,以下 .NET 类型同时支持索引和范围:String、Span 和 ReadOnlySpan。 List 支持索引,但不支持范围。
Array 具有更多的微妙行为。 单个维度数组同时支持索引和范围。 多维数组不支持索引器或范围。 多维数组的索引器具有多个参数,而不是一个参数。 交错数组(也称为数组的数组)同时支持范围和索引器。 下面的示例演示如何循环访问交错数组的矩形子节。 它循环访问位于中心的节,不包括前三行和后三行,以及每个选定行中的前两列和后两列:
csharp
int[][] jagged =
[
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[10,11,12,13,14,15,16,17,18,19],
[20,21,22,23,24,25,26,27,28,29],
[30,31,32,33,34,35,36,37,38,39],
[40,41,42,43,44,45,46,47,48,49],
[50,51,52,53,54,55,56,57,58,59],
[60,61,62,63,64,65,66,67,68,69],
[70,71,72,73,74,75,76,77,78,79],
[80,81,82,83,84,85,86,87,88,89],
[90,91,92,93,94,95,96,97,98,99],
];
var selectedRows = jagged[3..^3];
foreach (var row in selectedRows)
{
var selectedColumns = row[2..^2];
foreach (var cell in selectedColumns)
{
Console.Write($"{cell}, ");
}
Console.WriteLine();
}
在所有情况下,Array 的范围运算符都会分配一个数组来存储返回的元素。
索引和范围的应用场景
要分析较大序列的一部分时,通常会使用范围和索引。 在准确读取所涉及的序列部分这一方面,新语法更清晰。 本地函数 MovingAverage
以 Range 为参数。 然后,该方法在计算最小值、最大值和平均值时仅枚举该范围。 在项目中尝试以下代码:
csharp
int[] sequence = Sequence(1000);
for(int start = 0; start < sequence.Length; start += 100)
{
Range r = start..(start+10);
var (min, max, average) = MovingAverage(sequence, r);
Console.WriteLine($"From {r.Start} to {r.End}: \tMin: {min},\tMax: {max},\tAverage: {average}");
}
for (int start = 0; start < sequence.Length; start += 100)
{
Range r = ^(start + 10)..^start;
var (min, max, average) = MovingAverage(sequence, r);
Console.WriteLine($"From {r.Start} to {r.End}: \tMin: {min},\tMax: {max},\tAverage: {average}");
}
(int min, int max, double average) MovingAverage(int[] subSequence, Range range) =>
(
subSequence[range].Min(),
subSequence[range].Max(),
subSequence[range].Average()
);
int[] Sequence(int count) => [..Enumerable.Range(0, count).Select(x => (int)(Math.Sqrt(x) * 100))];
关于范围索引和数组的说明
从数组中获取范围时,结果是从初始数组复制的数组,而不是引用的数组。 修改生成的数组中的值不会更改初始数组中的值。
例如:
csharp
var arrayOfFiveItems = new[] { 1, 2, 3, 4, 5 };
var firstThreeItems = arrayOfFiveItems[..3]; // contains 1,2,3
firstThreeItems[0] = 11; // now contains 11,2,3
Console.WriteLine(string.Join(",", firstThreeItems));
Console.WriteLine(string.Join(",", arrayOfFiveItems));
// output:
// 11,2,3
// 1,2,3,4,5
partial分部类和方法
拆分一个类、一个结构、一个接口或一个方法的定义到两个或更多的文件中是可能的。 每个源文件包含类型或方法定义的一部分,编译应用程序时将把所有部分组合起来。
分部类
在以下几种情况下需要拆分类定义:
- 处理大型项目时,使一个类分布于多个独立文件中可以让多位程序员同时对该类进行处理。
- 当使用自动生成的源文件时,你可以添加代码而不需要重新创建源文件。 Visual Studio 在创建Windows 窗体、Web 服务包装器代码等时会使用这种方法。 你可以创建使用这些类的代码,这样就不需要修改由Visual Studio生成的文件。
- 使用源生成器在类中生成附加功能时。
若要拆分类定义,请使用 partial 关键字修饰符,如下所示:
csharp
public partial class Employee
{
public void DoWork()
{
}
}
public partial class Employee
{
public void GoToLunch()
{
}
}
partial
关键字指示可在命名空间中定义该类、结构或接口的其他部分。 所有部分都必须使用 partial
关键字。 在编译时,各个部分都必须可用来形成最终的类型。 各个部分必须具有相同的可访问性,如 public
、private
等。
如果将任意部分声明为抽象的,则整个类型都被视为抽象的。 如果将任意部分声明为密封的,则整个类型都被视为密封的。 如果任意部分声明基类型,则整个类型都将继承该类。
指定基类的所有部分必须一致,但忽略基类的部分仍继承该基类型。 各个部分可以指定不同的基接口,最终类型将实现所有分部声明所列出的全部接口。 在某一分部定义中声明的任何类、结构或接口成员可供所有其他部分使用。 最终类型是所有部分在编译时的组合。
备注
partial
修饰符不可用于委托或枚举声明中。
下面的示例演示嵌套类型可以是分部的,即使它们所嵌套于的类型本身并不是分部的也如此。
csharp
class Container
{
partial class Nested
{
void Test() { }
}
partial class Nested
{
void Test2() { }
}
}
编译时会对分部类型定义的属性进行合并。 以下面的声明为例:
csharp
[SerializableAttribute]
partial class Moon { }
[ObsoleteAttribute]
partial class Moon { }
它们等效于以下声明:
csharp
[SerializableAttribute]
[ObsoleteAttribute]
class Moon { }
将从所有分部类型定义中对以下内容进行合并:
- XML 注释
- 接口
- 泛型类型参数属性
- class 特性
- 成员
以下面的声明为例:
csharp
partial class Earth : Planet, IRotate { }
partial class Earth : IRevolve { }
它们等效于以下声明:
csharp
class Earth : Planet, IRotate, IRevolve { }
限制
处理分部类定义时需遵循下面的几个规则:
-
要作为同一类型的各个部分的所有分部类型定义都必须使用
partial
进行修饰。 例如,下面的类声明会生成错误:
csharppublic partial class A { } //public class A { } // Error, must also be marked partial
-
partial
修饰符只能出现在紧靠关键字class
、struct
或interface
前面的位置。 -
分部类型定义中允许使用嵌套的分部类型,如下面的示例中所示:
csharppartial class ClassWithNestedClass { partial class NestedClass { } } partial class ClassWithNestedClass { partial class NestedClass { } }
-
要成为同一类型的各个部分的所有分部类型定义都必须在同一程序集和同一模块(.exe 或 .dll 文件)中进行定义。 分部定义不能跨越多个模块。
-
类名和泛型类型参数在所有的分部类型定义中都必须匹配。 泛型类型可以是分部的。 每个分部声明都必须以相同的顺序使用相同的参数名。
-
下面用于分部类型定义中的关键字是可选的,但是如果某关键字出现在一个分部类型定义中,则该关键字不能与在同一类型的其他分部定义中指定的关键字冲突:
有关详细信息,请参阅类型参数的约束。
示例
下面的示例在一个分部类定义中声明 Coords
类的字段和构造函数,在另一个分部类定义中声明成员 PrintCoords
。
csharp
public partial class Coords
{
private int x;
private int y;
public Coords(int x, int y)
{
this.x = x;
this.y = y;
}
}
public partial class Coords
{
public void PrintCoords()
{
Console.WriteLine("Coords: {0},{1}", x, y);
}
}
class TestCoords
{
static void Main()
{
Coords myCoords = new Coords(10, 15);
myCoords.PrintCoords();
// Keep the console window open in debug mode.
Console.WriteLine("Press any key to exit.");
Console.ReadKey();
}
}
// Output: Coords: 10,15
从下面的示例可以看出,你也可以开发分部结构和接口。
csharp
partial interface ITest
{
void Interface_Test();
}
partial interface ITest
{
void Interface_Test2();
}
partial struct S1
{
void Struct_Test() { }
}
partial struct S1
{
void Struct_Test2() { }
}
分部方法
分部类或结构可以包含分部方法。 类的一个部分包含方法的签名。 可以在同一部分或另一部分中定义实现。 如果未提供该实现,则会在编译时删除方法以及对方法的所有调用。 根据方法签名,可能需要实现。 在以下情况下,不需要使用分部方法即可实现:
任何不符合所有这些限制的方法(例如 public virtual partial void
方法)都必须提供实现。 此实现可以由源生成器提供。
分部方法允许类的某个部分的实现者声明方法。 类的另一部分的实现者可以定义该方法。 在以下两个情形中,此方法很有用:生成样板代码的模板和源生成器。
- 模板代码:模板保留方法名称和签名,以使生成的代码可以调用方法。 这些方法遵循允许开发人员决定是否实现方法的限制。 如果未实现该方法,编译器会删除方法签名以及对该方法的所有调用。 调用该方法(包括调用中的任何参数计算结果)在运行时没有任何影响。 因此,分部类中的任何代码都可以随意地使用分部方法,即使未提供实现也是如此。 调用但不实现该方法不会导致编译时错误或运行时错误。
- 源生成器:源生成器提供方法的实现。 开发人员可以添加方法声明(通常由源生成器读取属性)。 开发人员可以编写调用这些方法的代码。 源生成器在编译过程中运行并提供实现。 在这种情况下,不会遵循不经常实现的分部方法的限制。
csharp
// Definition in file1.cs
partial void OnNameChanged();
// Implementation in file2.cs
partial void OnNameChanged()
{
// method body
}
- 分部方法声明必须以上下文关键字 partial 开头。
- 分部类型的两个部分中的分部方法签名必须匹配。
- 分部方法可以有 static 和 unsafe 修饰符。
- 分部方法可以是泛型的。 约束将放在定义分部方法声明上,但也可以选择重复放在实现声明上。 参数和类型参数名称在实现声明和定义声明中不必相同。
- 你可以为已定义并实现的分部方法生成委托,但不能为已经定义但未实现的分部方法生成委托。
局部类的适用范围
- 类型特别大,不适合放在一个文件中实现
- 自动生成的代码,不宜与我们自己编写的代码混合在一起
- 一个类同时需要多个人同时编写的时候
注意事项
- 只适用于类、接口、结构,不支持委托和枚举
- 必须有修饰符partial
- 必须位于相同的命名空间中
- 必须同时编译
- 各部分的访问修饰符必须一致
继承与组合
OOP设计法则:复合优于继承
继承
继承 是面向对象的编程的一种基本特性。 借助继承,能够定义可重用(继承)、扩展或修改父类行为的子类。 成员被继承的类称为基类 。 继承基类成员的类称为派生类。
C# 和 .NET 只支持单一继承 。 也就是说,类只能继承自一个类。 不过,继承是可传递的。这样一来,就可以为一组类型定义继承层次结构。 换言之,类型 D
可继承自类型 C
,其中类型 C
继承自类型 B
,类型 B
又继承自基类类型 A
。 由于继承是可传递的,因此类型 D
继承了类型 A
的成员。
并非所有基类成员都可供派生类继承。 以下成员无法继承:
虽然基类的其他所有成员都可供派生类继承,但这些成员是否可见取决于它们的可访问性。 成员的可访问性决定了其是否在派生类中可见,如下所述:
-
只有在基类中嵌套的派生类中,私有成员才可见。 否则,此类成员在派生类中不可见。 在以下示例中,
A.B
是派生自A
的嵌套类,而C
则派生自A
。 私有A._value
字段在 A.B 中可见。不过,如果从C.GetValue
方法中删除注释并尝试编译示例,则会生成编译器错误 CS0122:""A._value" 不可访问,因为它具有一定的保护级别。"csharppublic class A { private int _value = 10; public class B : A { public int GetValue() { return _value; } } } public class C : A { // public int GetValue() // { // return _value; // } } public class AccessExample { public static void Main(string[] args) { var b = new A.B(); Console.WriteLine(b.GetValue()); } } // The example displays the following output: // 10
-
受保护成员仅在派生类中可见。
-
内部成员仅在与基类同属一个程序集的派生类中可见, 在与基类属于不同程序集的派生类中不可见。
-
公共成员在派生类中可见,并且属于派生类的公共接口。 可以调用继承的公共成员,就像它们是在派生类中定义一样。 在以下示例中,类
A
定义Method1
方法,类B
继承自类A
。 然后,以下示例调用Method1
,就像它是B
中的实例方法一样。csharppublic class A { public void Method1() { // Method implementation. } } public class B : A { } public class Example { public static void Main() { B b = new (); b.Method1(); } }
派生类还可以通过提供重写实现代码来重写 继承的成员。 基类成员必须标记有 virtual 关键字,才能重写继承的成员。 默认情况下,基类成员没有 virtual
标记,因此无法被重写。 如果尝试重写非虚成员(如以下示例所示),则会生成编译器错误 CS0506:"<member>
无法重写继承的成员 <member>
,因为继承的成员没有 virtual、abstract 或 override 标记。"
csharp
public class A
{
public void Method1()
{
// Do something.
}
}
public class B : A
{
public override void Method1() // Generates CS0506.
{
// Do something else.
}
}
在某些情况下,派生类必须 重写基类实现代码。 标记有 abstract 关键字的基类成员要求派生类必须重写它们。 如果尝试编译以下示例,则会生成编译器错误 CS0534:"<class>
不实现继承的抽象成员 <member>
",因为类 B
没有提供 A.Method1
的实现代码。
csharp
public abstract class A
{
public abstract void Method1();
}
public class B : A // Generates CS0534.
{
public void Method3()
{
// Do something.
}
}
继承仅适用于类和接口。 其他各种类型(结构、委托和枚举)均不支持继承。 由于这些规则,尝试编译类似以下示例的代码会产生编译器错误 CS0527:"接口列表中的类型 "ValueType" 不是一个接口。"该错误消息指示,尽管可定义结构所实现的接口,但不支持继承。
csharp
public struct ValueStructure : ValueType // Generates CS0527.
{
}
隐式继承
.NET 类型系统中的所有类型除了可以通过单一继承进行继承之外,还可以隐式继承自 Object 或其派生的类型。 Object 的常用功能可用于任何类型。
为了说明隐式继承的具体含义,让我们来定义一个新类 SimpleClass
,这只是一个空类定义:
csharp
public class SimpleClass
{ }
然后可以使用反射(便于检查类型的元数据,从而获取此类型的相关信息),获取 SimpleClass
类型的成员列表。 尽管没有在 SimpleClass
类中定义任何成员,但示例输出表明它实际上有九个成员。 这些成员的其中之一是由 C# 编译器自动为 SimpleClass
类型提供的无参数(或默认)构造函数。 剩余八个是 Object(.NET 类型系统中的所有类和接口最终隐式继承自的类型)的成员。
csharp
using System.Reflection;
public class SimpleClassExample
{
public static void Main()
{
Type t = typeof(SimpleClass);
BindingFlags flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public |
BindingFlags.NonPublic | BindingFlags.FlattenHierarchy;
MemberInfo[] members = t.GetMembers(flags);
Console.WriteLine($"Type {t.Name} has {members.Length} members: ");
foreach (MemberInfo member in members)
{
string access = "";
string stat = "";
var method = member as MethodBase;
if (method != null)
{
if (method.IsPublic)
access = " Public";
else if (method.IsPrivate)
access = " Private";
else if (method.IsFamily)
access = " Protected";
else if (method.IsAssembly)
access = " Internal";
else if (method.IsFamilyOrAssembly)
access = " Protected Internal ";
if (method.IsStatic)
stat = " Static";
}
string output = $"{member.Name} ({member.MemberType}): {access}{stat}, Declared by {member.DeclaringType}";
Console.WriteLine(output);
}
}
}
// The example displays the following output:
// Type SimpleClass has 9 members:
// ToString (Method): Public, Declared by System.Object
// Equals (Method): Public, Declared by System.Object
// Equals (Method): Public Static, Declared by System.Object
// ReferenceEquals (Method): Public Static, Declared by System.Object
// GetHashCode (Method): Public, Declared by System.Object
// GetType (Method): Public, Declared by System.Object
// Finalize (Method): Internal, Declared by System.Object
// MemberwiseClone (Method): Internal, Declared by System.Object
// .ctor (Constructor): Public, Declared by SimpleClass
由于隐式继承自 Object 类,因此 SimpleClass
类可以使用下面这些方法:
- 公共
ToString
方法将SimpleClass
对象转换为字符串表示形式,返回完全限定的类型名称。 在这种情况下,ToString
方法返回字符串"SimpleClass"。 - 三个用于测试两个对象是否相等的方法:公共实例
Equals(Object)
方法、公共静态Equals(Object, Object)
方法和公共静态ReferenceEquals(Object, Object)
方法。 默认情况下,这三个方法测试的是引用相等性;也就是说,两个对象变量必须引用同一个对象,才算相等。 - 公共
GetHashCode
方法:计算允许在经哈希处理的集合中使用类型实例的值。 - 公共
GetType
方法:返回表示SimpleClass
类型的 Type 对象。 - 受保护 Finalize 方法:用于在垃圾回收器回收对象的内存之前释放非托管资源。
- 受保护 MemberwiseClone 方法:创建当前对象的浅表复制。
由于是隐式继承,因此可以调用 SimpleClass
对象中任何继承的成员,就像它实际上是 SimpleClass
类中定义的成员一样。 例如,下面的示例调用 SimpleClass
从 Object 继承而来的 SimpleClass.ToString
方法。
csharp
public class EmptyClass
{ }
public class ClassNameExample
{
public static void Main()
{
EmptyClass sc = new();
Console.WriteLine(sc.ToString());
}
}
// The example displays the following output:
// EmptyClass
下表列出了可以在 C# 中创建的各种类型及其隐式继承自的类型。 每个基类型通过继承向隐式派生的类型提供一组不同的成员。
展开表
类型类别 | 隐式继承自 |
---|---|
class | Object |
struct | ValueType, Object |
enum | Enum, ValueType, Object |
delegate | MulticastDelegate, Delegate, Object |
继承和"is a"关系
通常情况下,继承用于表示基类和一个或多个派生类之间的"is a"关系,其中派生类是基类的特定版本;派生类是基类的具体类型。 例如,Publication
类表示任何类型的出版物,Book
和 Magazine
类表示出版物的具体类型。
备注
一个类或结构可以实现一个或多个接口。 虽然接口实现代码通常用作单一继承的解决方法或对结构使用继承的方法,但它旨在表示接口与其实现类型之间的不同关系(即"can do"关系),而不是继承关系。 接口定义了其向实现类型提供的一部分功能(如测试相等性、比较或排序对象,或支持区域性敏感的分析和格式设置)。
请注意,"is a"还表示类型与其特定实例化之间的关系。 在以下示例中,Automobile
类包含三个唯一只读属性:Make
(汽车制造商)、Model
(汽车型号)和 Year
(汽车出厂年份)。 Automobile
类还有一个自变量被分配给属性值的构造函数,并将 Object.ToString 方法重写为生成唯一标识 Automobile
实例(而不是 Automobile
类)的字符串。
csharp
public class Automobile
{
public Automobile(string make, string model, int year)
{
if (make == null)
throw new ArgumentNullException(nameof(make), "The make cannot be null.");
else if (string.IsNullOrWhiteSpace(make))
throw new ArgumentException("make cannot be an empty string or have space characters only.");
Make = make;
if (model == null)
throw new ArgumentNullException(nameof(model), "The model cannot be null.");
else if (string.IsNullOrWhiteSpace(model))
throw new ArgumentException("model cannot be an empty string or have space characters only.");
Model = model;
if (year < 1857 || year > DateTime.Now.Year + 2)
throw new ArgumentException("The year is out of range.");
Year = year;
}
public string Make { get; }
public string Model { get; }
public int Year { get; }
public override string ToString() => $"{Year} {Make} {Model}";
}
在这种情况下,不得依赖继承来表示特定汽车品牌和型号。 例如,不需要定义 Packard
类型来表示帕卡德制造的汽车。 相反,可以通过创建将相应值传递给其类构造函数的 Automobile
对象来进行表示,如以下示例所示。
csharp
using System;
public class Example
{
public static void Main()
{
var packard = new Automobile("Packard", "Custom Eight", 1948);
Console.WriteLine(packard);
}
}
// The example displays the following output:
// 1948 Packard Custom Eight
基于继承的"is a"关系最适用于基类和向基类添加附加成员或需要基类没有的其他功能的派生类。
base
base
关键字用于从派生类中访问基类的成员。 如果要执行以下操作时使用它:
- 调用基类上已被其他方法重写的方法。
- 指定创建派生类实例时应调用的基类构造函数。
仅允许基类访问在构造函数、实例方法和实例属性访问器中进行。
在静态方法中使用 base
关键字将产生错误。
所访问的基类是类声明中指定的基类。 例如,如果指定 class ClassB : ClassA
,则从 ClassB 访问 ClassA 的成员,而不考虑 ClassA 的基类。
示例 1
在此示例中,基类 Person
和派生类 Employee
都有一个名为 GetInfo
的方法。 通过使用 base
关键字,可以从派生类中调用基类的 GetInfo
方法。
csharp
public class Person
{
protected string ssn = "444-55-6666";
protected string name = "John L. Malgraine";
public virtual void GetInfo()
{
Console.WriteLine("Name: {0}", name);
Console.WriteLine("SSN: {0}", ssn);
}
}
class Employee : Person
{
public string id = "ABC567EFG";
public override void GetInfo()
{
// Calling the base class GetInfo method:
base.GetInfo();
Console.WriteLine("Employee ID: {0}", id);
}
}
class TestClass
{
static void Main()
{
Employee E = new Employee();
E.GetInfo();
}
}
/*
Output
Name: John L. Malgraine
SSN: 444-55-6666
Employee ID: ABC567EFG
*/
有关其他示例,请参阅 new、virtual 和 override。
示例 2
本示例显示如何指定在创建派生类实例时调用的基类构造函数。
csharp
public class BaseClass
{
int num;
public BaseClass()
{
Console.WriteLine("in BaseClass()");
}
public BaseClass(int i)
{
num = i;
Console.WriteLine("in BaseClass(int i)");
}
public int GetNum()
{
return num;
}
}
public class DerivedClass : BaseClass
{
// This constructor will call BaseClass.BaseClass()
public DerivedClass() : base()
{
}
// This constructor will call BaseClass.BaseClass(int i)
public DerivedClass(int i) : base(i)
{
}
static void Main()
{
DerivedClass md = new DerivedClass();
DerivedClass md1 = new DerivedClass(1);
}
}
/*
Output:
in BaseClass()
in BaseClass(int i)
*/
强制转换和类型转换
由于 C# 是在编译时静态类型化的,因此变量在声明后就无法再次声明,或无法分配另一种类型的值,除非该类型可以隐式转换为变量的类型。 例如,string
无法隐式转换为 int
。 因此,在将 i
声明为 int
后,无法将字符串"Hello"分配给它,如以下代码所示:
csharp
int i;
// error CS0029: Cannot implicitly convert type 'string' to 'int'
i = "Hello";
但有时可能需要将值复制到其他类型的变量或方法参数中。 例如,可能需要将一个整数变量传递给参数类型化为 double
的方法。 或者可能需要将类变量分配给接口类型的变量。 这些类型的操作称为类型转换。 在 C# 中,可以执行以下几种类型的转换:
- 隐式转换:由于这种转换始终会成功且不会导致数据丢失,因此无需使用任何特殊语法。 示例包括从较小整数类型到较大整数类型的转换以及从派生类到基类的转换。
- 显式转换(强制转换) :必须使用强制转换表达式,才能执行显式转换。 在转换中可能丢失信息时或在出于其他原因转换可能不成功时,必须进行强制转换。 典型的示例包括从数值到精度较低或范围较小的类型的转换和从基类实例到派生类的转换。
- 用户定义的转换 :用户定义的转换是使用特殊方法执行,这些方法可定义为在没有基类和派生类关系的自定义类型之间启用显式转换和隐式转换。 有关详细信息,请参阅用户定义转换运算符。
- 使用帮助程序类进行转换 :若要在非兼容类型(如整数和 System.DateTime 对象,或十六进制字符串和字节数组)之间转换,可使用 System.BitConverter 类、System.Convert 类和内置数值类型的
Parse
方法(如 Int32.Parse)。 有关详细信息,请参见如何将字节数组转换为 int、如何将字符串转换为数字和如何在十六进制字符串与数值类型之间转换。
隐式转换
对于内置数值类型,如果要存储的值无需截断或四舍五入即可适应变量,则可以进行隐式转换。 对于整型类型,这意味着源类型的范围是目标类型范围的正确子集。 例如,long 类型的变量(64 位整数)能够存储 int(32 位整数)可存储的任何值。 在下面的示例中,编译器先将右侧的 num
值隐式转换为 long
类型,再将它赋给 bigNum
。
csharp
// Implicit conversion. A long can
// hold any value an int can hold, and more!
int num = 2147483647;
long bigNum = num;
有关所有隐式数值转换的完整列表,请参阅内置数值转换一文的隐式数值转换表部分。
对于引用类型,隐式转换始终存在于从一个类转换为该类的任何一个直接或间接的基类或接口的情况。 由于派生类始终包含基类的所有成员,因此不必使用任何特殊语法。
csharp
Derived d = new Derived();
// Always OK.
Base b = d;
显式转换
但是,如果进行转换可能会导致信息丢失,则编译器会要求执行显式转换,显式转换也称为强制转换。 强制转换是显式告知编译器以下信息的一种方式:你打算进行转换且你知道可能会发生数据丢失,或者你知道强制转换有可能在运行时失败。 若要执行强制转换,请在要转换的值或变量前面的括号中指定要强制转换到的类型。 下面的程序将 double 强制转换为 int。如不强制转换则该程序不会进行编译。
csharp
class Test
{
static void Main()
{
double x = 1234.7;
int a;
// Cast double to int.
a = (int)x;
System.Console.WriteLine(a);
}
}
// Output: 1234
有关支持的显式数值转换的完整列表,请参阅内置数值转换一文的显式数值转换部分。
对于引用类型,如果需要从基类型转换为派生类型,则必须进行显式强制转换:
csharp
// Create a new derived type.
Giraffe g = new Giraffe();
// Implicit conversion to base type is safe.
Animal a = g;
// Explicit conversion is required to cast back
// to derived type. Note: This will compile but will
// throw an exception at run time if the right-side
// object is not in fact a Giraffe.
Giraffe g2 = (Giraffe)a;
引用类型之间的强制转换操作不会更改基础对象的运行时类型;它只更改用作对该对象引用的值的类型。 有关详细信息,请参阅多态性。
运行时的类型转换异常
在某些引用类型转换中,编译器无法确定强制转换是否会有效。 正确进行编译的强制转换操作有可能在运行时失败。 如下面的示例所示,类型转换在运行时失败将导致引发 InvalidCastException。
csharp
class Animal
{
public void Eat() => System.Console.WriteLine("Eating.");
public override string ToString() => "I am an animal.";
}
class Reptile : Animal { }
class Mammal : Animal { }
class UnSafeCast
{
static void Main()
{
Test(new Mammal());
// Keep the console window open in debug mode.
System.Console.WriteLine("Press any key to exit.");
System.Console.ReadKey();
}
static void Test(Animal a)
{
// System.InvalidCastException at run time
// Unable to cast object of type 'Mammal' to type 'Reptile'
Reptile r = (Reptile)a;
}
}
Test
方法有一个 Animal
形式参数,因此,将实际参数 a
显式强制转换为 Reptile
会造成危险的假设。 更安全的做法是不要做出假设,而是检查类型。 C# 提供 is 运算符,使你可以在实际执行强制转换之前测试兼容性。 有关详细信息,请参阅如何使用模式匹配以及 as 和 is 运算符安全地进行强制转换。
装箱和取消装箱
装箱是将值类型转换为 object
类型或由此值类型实现的任何接口类型的过程。 常见语言运行时 (CLR) 对值类型进行装箱时,会将值包装在 System.Object 实例中并将其存储在托管堆中。 取消装箱将从对象中提取值类型。 装箱是隐式的;取消装箱是显式的。 装箱和取消装箱的概念是类型系统 C# 统一视图的基础,其中任一类型的值都被视为一个对象。
下例将整型变量 i
进行了装箱并分配给对象 o
。
csharp
int i = 123;
// The following line boxes i.
object o = i;
然后,可以将对象 o
取消装箱并分配给整型变量 i
:
csharp
o = 123;
i = (int)o; // unboxing
以下示例演示如何在 C# 中使用装箱。
csharp
// String.Concat example.
// String.Concat has many versions. Rest the mouse pointer on
// Concat in the following statement to verify that the version
// that is used here takes three object arguments. Both 42 and
// true must be boxed.
Console.WriteLine(String.Concat("Answer", 42, true));
// List example.
// Create a list of objects to hold a heterogeneous collection
// of elements.
List<object> mixedList = new List<object>();
// Add a string element to the list.
mixedList.Add("First Group:");
// Add some integers to the list.
for (int j = 1; j < 5; j++)
{
// Rest the mouse pointer over j to verify that you are adding
// an int to a list of objects. Each element j is boxed when
// you add j to mixedList.
mixedList.Add(j);
}
// Add another string and more integers.
mixedList.Add("Second Group:");
for (int j = 5; j < 10; j++)
{
mixedList.Add(j);
}
// Display the elements in the list. Declare the loop variable by
// using var, so that the compiler assigns its type.
foreach (var item in mixedList)
{
// Rest the mouse pointer over item to verify that the elements
// of mixedList are objects.
Console.WriteLine(item);
}
// The following loop sums the squares of the first group of boxed
// integers in mixedList. The list elements are objects, and cannot
// be multiplied or added to the sum until they are unboxed. The
// unboxing must be done explicitly.
var sum = 0;
for (var j = 1; j < 5; j++)
{
// The following statement causes a compiler error: Operator
// '*' cannot be applied to operands of type 'object' and
// 'object'.
//sum += mixedList[j] * mixedList[j];
// After the list elements are unboxed, the computation does
// not cause a compiler error.
sum += (int)mixedList[j] * (int)mixedList[j];
}
// The sum displayed is 30, the sum of 1 + 4 + 9 + 16.
Console.WriteLine("Sum: " + sum);
// Output:
// Answer42True
// First Group:
// 1
// 2
// 3
// 4
// Second Group:
// 5
// 6
// 7
// 8
// 9
// Sum: 30
性能
相对于简单的赋值而言,装箱和取消装箱过程需要进行大量的计算。 对值类型进行装箱时,必须分配并构造一个新对象。 取消装箱所需的强制转换也需要进行大量的计算,只是程度较轻。 有关更多信息,请参阅性能。
装箱
装箱用于在垃圾回收堆中存储值类型。 装箱是值类型到 object
类型或到此值类型所实现的任何接口类型的隐式转换。 对值类型装箱会在堆中分配一个对象实例,并将该值复制到新的对象中。
请看以下值类型变量的声明:
csharp
int i = 123;
以下语句对变量 i
隐式应用了装箱操作:
csharp
// Boxing copies the value of i into object o.
object o = i;
此语句的结果是在堆栈上创建对象引用 o
,而在堆上则引用 int
类型的值。 该值是赋给变量 i
的值类型值的一个副本。 以下装箱转换图说明了 i
和 o
这两个变量之间的差异:
还可以像下面的示例一样执行显式装箱,但显式装箱从来不是必需的:
csharp
int i = 123;
object o = (object)i; // explicit boxing
示例
此示例使用装箱将整型变量 i
转换为对象 o
。 这样一来,存储在变量 i
中的值就从 123
更改为 456
。 该示例表明原始值类型和装箱的对象使用不同的内存位置,因此能够存储不同的值。
csharp
class TestBoxing
{
static void Main()
{
int i = 123;
// Boxing copies the value of i into object o.
object o = i;
// Change the value of i.
i = 456;
// The change in i doesn't affect the value stored in o.
System.Console.WriteLine("The value-type value = {0}", i);
System.Console.WriteLine("The object-type value = {0}", o);
}
}
/* Output:
The value-type value = 456
The object-type value = 123
*/
取消装箱
取消装箱是从 object
类型到值类型或从接口类型到实现该接口的值类型的显式转换。 取消装箱操作包括:
- 检查对象实例,以确保它是给定值类型的装箱值。
- 将该值从实例复制到值类型变量中。
下面的语句演示装箱和取消装箱两种操作:
csharp
int i = 123; // a value type
object o = i; // boxing
int j = (int)o; // unboxing
下图演示了上述语句的结果:
要在运行时成功取消装箱值类型,被取消装箱的项必须是对一个对象的引用,该对象是先前通过装箱该值类型的实例创建的。 尝试取消装箱 null
会导致 NullReferenceException。 尝试取消装箱对不兼容值类型的引用会导致 InvalidCastException。
示例
下面的示例演示无效的取消装箱及引发的 InvalidCastException
。 使用 try
和 catch
,在发生错误时显示错误信息。
csharp
class TestUnboxing
{
static void Main()
{
int i = 123;
object o = i; // implicit boxing
try
{
int j = (short)o; // attempt to unbox
System.Console.WriteLine("Unboxing OK.");
}
catch (System.InvalidCastException e)
{
System.Console.WriteLine("{0} Error: Incorrect unboxing.", e.Message);
}
}
}
此程序输出:
Specified cast is not valid. Error: Incorrect unboxing.
如果将下列语句:
csharp
int j = (short)o;
更改为:
csharp
int j = (int)o;
将执行转换,并将得到以下输出:
Unboxing OK.
多态
虚方法(virtual)与方法重写(override)
virtual
virtual
关键字用于修改方法、属性、索引器或事件声明,并使它们可以在派生类中被重写。 例如,此方法可被任何继承它的类替代:
csharp
public virtual double Area()
{
return x * y;
}
虚拟成员的实现可由派生类中的替代成员更改。 有关如何使用 virtual
关键字的详细信息,请参阅使用 Override 和 New 关键字进行版本控制和了解何时使用 Override 和 New 关键字。
备注
调用虚拟方法时,将为替代的成员检查该对象的运行时类型。 将调用大部分派生类中的该替代成员,如果没有派生类替代该成员,则它可能是原始成员。
默认情况下,方法是非虚拟的。 不能替代非虚方法。
virtual
修饰符不能与 static
、abstract``private
或 override
修饰符一起使用。 以下示例显示了虚拟属性:
csharp
class MyBaseClass
{
// virtual auto-implemented property. Overrides can only
// provide specialized behavior if they implement get and set accessors.
public virtual string Name { get; set; }
// ordinary virtual property with backing field
private int _num;
public virtual int Number
{
get { return _num; }
set { _num = value; }
}
}
class MyDerivedClass : MyBaseClass
{
private string _name;
// Override auto-implemented property with ordinary property
// to provide specialized accessor behavior.
public override string Name
{
get
{
return _name;
}
set
{
if (!string.IsNullOrEmpty(value))
{
_name = value;
}
else
{
_name = "Unknown";
}
}
}
}
除声明和调用语法不同外,虚拟属性的行为与虚拟方法相似。
- 在静态属性上使用
virtual
修饰符是错误的。 - 通过包括使用
override
修饰符的属性声明,可在派生类中替代虚拟继承属性。
示例
在该示例中,Shape
类包含 x
、y
两个坐标和 Area()
虚拟方法。 不同的形状类(如 Circle
、Cylinder
和 Sphere
)继承 Shape
类,并为每个图形计算表面积。 每个派生类都有各自的 Area()
替代实现。
请注意,继承的类 Circle``Cylinder
和 Sphere
均使用初始化基类的构造函数,如下面的声明中所示。
csharp
public Cylinder(double r, double h): base(r, h) {}
根据与方法关联的对象,下面的程序通过调用 Area()
方法的相应实现来计算并显示每个对象的相应区域。
csharp
class TestClass
{
public class Shape
{
public const double PI = Math.PI;
protected double _x, _y;
public Shape()
{
}
public Shape(double x, double y)
{
_x = x;
_y = y;
}
public virtual double Area()
{
return _x * _y;
}
}
public class Circle : Shape
{
public Circle(double r) : base(r, 0)
{
}
public override double Area()
{
return PI * _x * _x;
}
}
public class Sphere : Shape
{
public Sphere(double r) : base(r, 0)
{
}
public override double Area()
{
return 4 * PI * _x * _x;
}
}
public class Cylinder : Shape
{
public Cylinder(double r, double h) : base(r, h)
{
}
public override double Area()
{
return 2 * PI * _x * _x + 2 * PI * _x * _y;
}
}
static void Main()
{
double r = 3.0, h = 5.0;
Shape c = new Circle(r);
Shape s = new Sphere(r);
Shape l = new Cylinder(r, h);
// Display results.
Console.WriteLine("Area of Circle = {0:F2}", c.Area());
Console.WriteLine("Area of Sphere = {0:F2}", s.Area());
Console.WriteLine("Area of Cylinder = {0:F2}", l.Area());
}
}
/*
Output:
Area of Circle = 28.27
Area of Sphere = 113.10
Area of Cylinder = 150.80
*/
override
扩展或修改继承的方法、属性、索引器或事件的抽象或虚拟实现需要 override
修饰符。
在以下示例中,Square
类必须提供 GetArea
的重写实现,因为 GetArea
继承自抽象 Shape
类:
csharp
abstract class Shape
{
public abstract int GetArea();
}
class Square : Shape
{
private int _side;
public Square(int n) => _side = n;
// GetArea method is required to avoid a compile-time error.
public override int GetArea() => _side * _side;
static void Main()
{
var sq = new Square(12);
Console.WriteLine($"Area of the square = {sq.GetArea()}");
}
}
// Output: Area of the square = 144
override
方法提供从基类继承的方法的新实现。 通过 override
声明重写的方法称为重写基方法。 override
方法必须具有与重写基方法相同的签名。 override
方法支持协变返回类型。 具体而言,override
方法的返回类型可从相应基方法的返回类型派生。
不能重写非虚方法或静态方法。 重写基方法必须是 virtual
、abstract
或 override
。
override
声明不能更改 virtual
方法的可访问性。 override
方法和 virtual
方法必须具有相同级别访问修饰符。
不能使用 new
、static
或 virtual
修饰符修改 override
方法。
重写属性声明必须指定与继承的属性完全相同的访问修饰符、类型和名称。 只读重写属性支持协变返回类型。 重写属性必须为 virtual
、abstract
或 override
。
有关如何使用 override
关键字的详细信息,请参阅使用 Override 和 New 关键字进行版本控制和了解何时使用 Override 和 New 关键字。 有关继承的信息,请参阅继承。
示例
此示例定义一个名为 Employee
的基类和一个名为 SalesEmployee
的派生类。 SalesEmployee
类包含一个额外字段 salesbonus
,并且重写方法 CalculatePay
以将它考虑在内。
csharp
class TestOverride
{
public class Employee
{
public string Name { get; }
// Basepay is defined as protected, so that it may be
// accessed only by this class and derived classes.
protected decimal _basepay;
// Constructor to set the name and basepay values.
public Employee(string name, decimal basepay)
{
Name = name;
_basepay = basepay;
}
// Declared virtual so it can be overridden.
public virtual decimal CalculatePay()
{
return _basepay;
}
}
// Derive a new class from Employee.
public class SalesEmployee : Employee
{
// New field that will affect the base pay.
private decimal _salesbonus;
// The constructor calls the base-class version, and
// initializes the salesbonus field.
public SalesEmployee(string name, decimal basepay, decimal salesbonus)
: base(name, basepay)
{
_salesbonus = salesbonus;
}
// Override the CalculatePay method
// to take bonus into account.
public override decimal CalculatePay()
{
return _basepay + _salesbonus;
}
}
static void Main()
{
// Create some new employees.
var employee1 = new SalesEmployee("Alice", 1000, 500);
var employee2 = new Employee("Bob", 1200);
Console.WriteLine($"Employee1 {employee1.Name} earned: {employee1.CalculatePay()}");
Console.WriteLine($"Employee2 {employee2.Name} earned: {employee2.CalculatePay()}");
}
}
/*
Output:
Employee1 Alice earned: 1500
Employee2 Bob earned: 1200
*/
抽象类与抽象成员
abstract
abstract
修饰符指示被修改内容的实现已丢失或不完整。 abstract 修饰符可用于类、方法、属性、索引和事件。 在类声明中使用 abstract
修饰符来指示某个类仅用作其他类的基类,而不用于自行进行实例化。 标记为抽象的成员必须由派生自抽象类的非抽象类来实现。
示例 1
在此示例中,类 Square
必须提供 GetArea
的实现,因为它派生自 Shape
:
csharp
abstract class Shape
{
public abstract int GetArea();
}
class Square : Shape
{
private int _side;
public Square(int n) => _side = n;
// GetArea method is required to avoid a compile-time error.
public override int GetArea() => _side * _side;
static void Main()
{
var sq = new Square(12);
Console.WriteLine($"Area of the square = {sq.GetArea()}");
}
}
// Output: Area of the square = 144
抽象类具有以下功能:
- 抽象类不能实例化。
- 抽象类可能包含抽象方法和访问器。
- 无法使用 sealed 修饰符来修改抽象类,因为两个修饰符的含义相反。
sealed
修饰符阻止类被继承,而abstract
修饰符要求类被继承。 - 派生自抽象类的非抽象类,必须包含全部已继承的抽象方法和访问器的实际实现。
在方法或属性声明中使用 abstract
修饰符,以指示该方法或属性不包含实现。
抽象方法具有以下功能:
-
抽象方法是隐式的虚拟方法。
-
只有抽象类中才允许抽象方法声明。
-
由于抽象方法声明不提供实际的实现,因此没有方法主体;方法声明仅以分号结尾,且签名后没有大括号 ({ })。 例如:
csharppublic abstract void MyMethod();
实现由方法 override 提供,它是非抽象类的成员。
除了声明和调用语法方面不同外,抽象属性的行为与抽象方法相似。
- 在静态属性上使用
abstract
修饰符是错误的。 - 通过包含使用 override 修饰符的属性声明,可在派生类中重写抽象继承属性。
有关抽象类的详细信息,请参阅抽象类、密封类及类成员。
抽象类必须为所有接口成员提供实现。
实现接口的抽象类有可能将接口方法映射到抽象方法上。 例如:
csharp
interface I
{
void M();
}
abstract class C : I
{
public abstract void M();
}
示例 2
在此示例中,类 DerivedClass
派生自抽象类 BaseClass
。 抽象类包含抽象方法 AbstractMethod
,以及两个抽象属性 X
和 Y
。
csharp
// Abstract class
abstract class BaseClass
{
protected int _x = 100;
protected int _y = 150;
// Abstract method
public abstract void AbstractMethod();
// Abstract properties
public abstract int X { get; }
public abstract int Y { get; }
}
class DerivedClass : BaseClass
{
public override void AbstractMethod()
{
_x++;
_y++;
}
public override int X // overriding property
{
get
{
return _x + 10;
}
}
public override int Y // overriding property
{
get
{
return _y + 10;
}
}
static void Main()
{
var o = new DerivedClass();
o.AbstractMethod();
Console.WriteLine($"x = {o.X}, y = {o.Y}");
}
}
// Output: x = 111, y = 161
在前面的示例中,如果你尝试通过使用如下语句来实例化抽象类:
csharp
BaseClass bc = new BaseClass(); // Error
将遇到一个错误,告知编译器无法创建抽象类"BaseClass"的实例。
sealed类与sealed成员
应用于某个类时,sealed
修饰符可阻止其他类继承自该类。 在下面的示例中,类 B
继承自类 A
,但没有类可以继承自类 B
。
csharp
class A {}
sealed class B : A {}
还可以对替代基类中的虚方法或属性的方法或属性使用 sealed
修饰符。 这使你可以允许类派生自你的类并防止它们替代特定虚方法或属性。
示例
在下面的示例中,Z
继承自 Y
,但 Z
无法替代在 X
中声明并在 Y
中密封的虚函数 F
。
csharp
class X
{
protected virtual void F() { Console.WriteLine("X.F"); }
protected virtual void F2() { Console.WriteLine("X.F2"); }
}
class Y : X
{
sealed protected override void F() { Console.WriteLine("Y.F"); }
protected override void F2() { Console.WriteLine("Y.F2"); }
}
class Z : Y
{
// Attempting to override F causes compiler error CS0239.
// protected override void F() { Console.WriteLine("Z.F"); }
// Overriding F2 is allowed.
protected override void F2() { Console.WriteLine("Z.F2"); }
}
在类中定义新方法或属性时,可以通过不将它们声明为虚拟,来防止派生类替代它们。
将 abstract 修饰符与密封类结合使用是错误的,因为抽象类必须由提供抽象方法或属性的实现的类来继承。
应用于方法或属性时,sealed
修饰符必须始终与 override 结合使用。
因为结构是隐式密封的,所以无法继承它们。
有关详细信息,请参阅继承。
有关更多示例,请参阅抽象类、密封类及类成员。
csharp
sealed class SealedClass
{
public int x;
public int y;
}
class SealedTest2
{
static void Main()
{
var sc = new SealedClass();
sc.x = 110;
sc.y = 150;
Console.WriteLine($"x = {sc.x}, y = {sc.y}");
}
}
// Output: x = 110, y = 150
在上面的示例中,可能会尝试使用以下语句从密封类继承:
class MyDerivedC: SealedClass {} // Error
结果是出现错误消息:
'MyDerivedC': cannot derive from sealed type 'SealedClass'
备注
若要确定是否密封类、方法或属性,通常应考虑以下两点:
- 派生类通过可以自定义类而可能获得的潜在好处。
- 派生类可能采用使它们无法再正常工作或按预期工作的方式来修改类的可能性。
接口
接口包含非抽象 class
或 struct
必须实现的一组相关功能的定义。 接口可以定义 static
方法,此类方法必须具有实现。 接口可为成员定义默认实现。 接口不能声明实例数据,如字段、自动实现的属性或类似属性的事件。
例如,使用接口可以在类中包括来自多个源的行为。 该功能在 C# 中十分重要,因为该语言不支持类的多重继承。 此外,如果要模拟结构的继承,也必须使用接口,因为它们无法实际从另一个结构或类继承。
可使用 interface
关键字定义接口,如以下示例所示。
csharp
interface IEquatable<T>
{
bool Equals(T obj);
}
接口名称必须是有效的 C# 标识符名称。 按照约定,接口名称以大写字母 I
开头。
实现 IEquatable 接口的任何类或结构都必须包含与该接口指定的签名匹配的 Equals 方法的定义。 因此,可以依靠实现 IEquatable<T>
的类来包含 Equals
方法,类的实例可以通过该方法确定它是否等于相同类的另一个实例。
IEquatable<T>
的定义不为 Equals
提供实现。 类或结构可以实现多个接口,但是类只能从单个类继承。
有关抽象类的详细信息,请参阅抽象类、密封类及类成员。
接口可以包含实例方法、属性、事件、索引器或这四种成员类型的任意组合。 接口可以包含静态构造函数、字段、常量或运算符。 从 C# 11 开始,非字段接口成员可以是 static abstract
。 接口不能包含实例字段、实例构造函数或终结器。 接口成员默认是公共的,可以显式指定可访问性修饰符(如 public
、protected
、internal
、private
、protected internal
或 private protected
)。 private
成员必须有默认实现。
若要实现接口成员,实现类的对应成员必须是公共、非静态,并且具有与接口成员相同的名称和签名。
备注
当接口声明静态成员时,实现该接口的类型也可能声明具有相同签名的静态成员。 它们是不同的,并且由声明成员的类型唯一标识。 在类型中声明的静态成员不会覆盖接口中声明的静态成员。
实现接口的类或结构必须为所有已声明的成员提供实现,而非接口提供的默认实现。 但是,如果基类实现接口,则从基类派生的任何类都会继承该实现。
下面的示例演示 IEquatable 接口的实现。 实现类 Car
必须提供 Equals 方法的实现。
csharp
public class Car : IEquatable<Car>
{
public string? Make { get; set; }
public string? Model { get; set; }
public string? Year { get; set; }
// Implementation of IEquatable<T> interface
public bool Equals(Car? car)
{
return (this.Make, this.Model, this.Year) ==
(car?.Make, car?.Model, car?.Year);
}
}
类的属性和索引器可以为接口中定义的属性或索引器定义额外的访问器。 例如,接口可能会声明包含 get 取值函数的属性。 实现此接口的类可以声明包含 get
和 get
取值函数的同一属性。 但是,如果属性或索引器使用显式实现,则访问器必须匹配。 有关显式实现的详细信息,请参阅显式接口实现和接口属性。
接口可从一个或多个接口继承。 派生接口从其基接口继承成员。 实现派生接口的类必须实现派生接口中的所有成员,包括派生接口的基接口的所有成员。 该类可能会隐式转换为派生接口或任何其基接口。 类可能通过它继承的基类或通过其他接口继承的接口来多次包含某个接口。 但是,类只能提供接口的实现一次,并且仅当类将接口作为类定义的一部分 (class ClassName : InterfaceName
) 进行声明时才能提供。 如果由于继承实现接口的基类而继承了接口,则基类会提供接口的成员的实现。 但是,派生类可以重新实现任何虚拟接口成员,而不是使用继承的实现。 当接口声明方法的默认实现时,实现该接口的任何类都会继承该实现(你需要将类实例强制转换为接口类型,才能访问接口成员上的默认实现)。
基类还可以使用虚拟成员实现接口成员。 在这种情况下,派生类可以通过重写虚拟成员来更改接口行为。 有关虚拟成员的详细信息,请参阅多态性。
接口摘要
接口具有以下属性:
- 在 8.0 以前的 C# 版本中,接口类似于只有抽象成员的抽象基类。 实现接口的类或结构必须实现其所有成员。
- 从 C# 8.0 开始,接口可以定义其部分或全部成员的默认实现。 实现接口的类或结构不一定要实现具有默认实现的成员。 有关详细信息,请参阅默认接口方法。
- 接口无法直接进行实例化。 其成员由实现接口的任何类或结构来实现。
- 一个类或结构可以实现多个接口。 一个类可以继承一个基类,还可实现一个或多个接口。
使用 MSTest 和 .NET 进行 C# 单元测试
先决条件
创建源项目
打开 shell 窗口。 创建一个名为 unit-testing-using-mstest 的目录,用以保存解决方案。 在此新目录中,运行 dotnet new sln
为类库和测试项目创建新的解决方案文件。 创建 PrimeService 目录。 下图显示了当前的目录和文件结构:
控制台
console
/unit-testing-using-mstest
unit-testing-using-mstest.sln
/PrimeService
将 PrimeService 作为当前目录,然后运行 dotnet new classlib
以创建源项目。 将 Class1.cs 重命名为 PrimeService.cs 。 将文件中的代码替换为以下代码,以创建 PrimeService
类的失败实现:
csharp
using System;
namespace Prime.Services
{
public class PrimeService
{
public bool IsPrime(int candidate)
{
throw new NotImplementedException("Please create a test first.");
}
}
}
将目录更改回 unit-testing-using-mstest 目录。 运行 dotnet sln add
以向解决方案添加类库项目:
.NET CLI
dotnetcli
dotnet sln add PrimeService/PrimeService.csproj
创建测试项目
创建 PrimeService.Tests 目录。 下图显示了它的目录结构:
控制台
console
/unit-testing-using-mstest
unit-testing-using-mstest.sln
/PrimeService
Source Files
PrimeService.csproj
/PrimeService.Tests
将 PrimeService.Tests 目录作为当前目录,并使用 dotnet new mstest
创建一个新项目。 dotnet 新命令会创建一个将 MSTest 用作测试库的测试项目。 模板在 PrimeServiceTests.csproj 文件中配置测试运行器:
xml
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="2.1.1" />
<PackageReference Include="coverlet.collector" Version="1.3.0" />
</ItemGroup>
测试项目需要其他包创建和运行单元测试。 上一步中的 dotnet new
添加了 MSTest SDK、MSTest 测试框架、MSTest 运行器和 Coverlet 进行代码覆盖率报告。
将 PrimeService
类库作为另一个依赖项添加到项目中。 使用 dotnet add reference
命令:
.NET CLI
dotnetcli
dotnet add reference ../PrimeService/PrimeService.csproj
可以在 GitHub 上的示例存储库中看到整个文件。
下图显示了最终的解决方案布局:
控制台
console
/unit-testing-using-mstest
unit-testing-using-mstest.sln
/PrimeService
Source Files
PrimeService.csproj
/PrimeService.Tests
Test Source Files
PrimeServiceTests.csproj
切换到 unit-testing-using-mstest 目录,然后运行 dotnet sln add
:
.NET CLI
dotnetcli
dotnet sln add ./PrimeService.Tests/PrimeService.Tests.csproj
创建第一个测试
编写失败测试,使其通过,然后重复此过程。 从 PrimeService.Tests 目录删除 UnitTest1.cs ,并创建一个名为 PrimeService_IsPrimeShould.cs 且包含以下内容的新 C# 文件:
csharp
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Prime.Services;
namespace Prime.UnitTests.Services
{
[TestClass]
public class PrimeService_IsPrimeShould
{
private readonly PrimeService _primeService;
public PrimeService_IsPrimeShould()
{
_primeService = new PrimeService();
}
[TestMethod]
public void IsPrime_InputIs1_ReturnFalse()
{
bool result = _primeService.IsPrime(1);
Assert.IsFalse(result, "1 should not be prime");
}
}
}
TestClass 属性表示包含单元测试的类。 TestMethod 属性指示方法是测试方法。
保存此文件并执行 dotnet test
以构建测试和类库,然后运行测试。 MSTest 测试运行程序包含要运行测试的程序入口点。 dotnet test
使用已创建的单元测试项目启动测试运行程序。
测试失败。 尚未创建实现。 在起作用的 PrimeService
类中编写最简单的代码,使此测试通过:
csharp
public bool IsPrime(int candidate)
{
if (candidate == 1)
{
return false;
}
throw new NotImplementedException("Please create a test first.");
}
在 unit-testing-using-mstest 目录中,再次运行 dotnet test
。 dotnet test
命令构建 PrimeService
项目,然后构建 PrimeService.Tests
项目。 构建这两个项目后,该命令将运行此单项测试。 测试通过。
添加更多功能
你已经通过了一个测试,现在可以编写更多测试。 质数有其他几种简单情况:0,-1。 可使用 TestMethod 属性添加新测试,但这很快就会变得枯燥乏味。 还有其他 MSTest 属性,使用这些属性可编写类似测试的套件。 测试方法可以执行相同的代码,但具有不同的输入参数。 可以使用 DataRow 属性来指定这些输入的值。
可以不使用这两个属性创建新测试,而用来创建单个数据驱动的测试。 数据驱动的测试方法用于测试多个小于 2(即最小质数)的值。 在 PrimeService_IsPrimeShould.cs 中添加新的测试方法:
csharp
[TestMethod]
[DataRow(-1)]
[DataRow(0)]
[DataRow(1)]
public void IsPrime_ValuesLessThan2_ReturnFalse(int value)
{
var result = _primeService.IsPrime(value);
Assert.IsFalse(result, $"{value} should not be prime");
}
运行 dotnet test
,两项测试均失败。 若要使所有测试通过,可以在 PrimeService.cs 文件中更改 IsPrime
方法开头的 if
子句:
csharp
if (candidate < 2)
通过在主库中添加更多测试、理论和代码继续循环访问。 你将拥有已完成的测试版本和库的完整实现。
你已生成一个小型库和该库的一组单元测试。 你已将解决方案结构化,使添加新包和新测试成为了正常工作流的一部分。 你已将多数的时间和精力集中在解决应用程序的目标上。
控制反转与依赖注入
依赖关系注入 - .NET | Microsoft Learn
.NET 支持依赖关系注入 (DI) 软件设计模式,这是一种在类及其依赖项之间实现控制反转 (IoC) 的技术。 .NET 中的依赖关系注入是框架的内置部分,与配置、日志记录和选项模式一样。
依赖项是指另一个对象所依赖的对象。 使用其他类所依赖的 Write
方法检查以下 MessageWriter
类:
csharp
public class MessageWriter
{
public void Write(string message)
{
Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
}
}
类可以创建 MessageWriter
类的实例,以便利用其 Write
方法。 在以下示例中,MessageWriter
类是 Worker
类的依赖项:
csharp
public class Worker : BackgroundService
{
private readonly MessageWriter _messageWriter = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1_000, stoppingToken);
}
}
}
该类创建并直接依赖于 MessageWriter
类。 硬编码的依赖项(如前面的示例)会产生问题,应避免使用,原因如下:
- 要用不同的实现替换
MessageWriter
,必须修改Worker
类。 - 如果
MessageWriter
具有依赖项,则必须由Worker
类对其进行配置。 在具有多个依赖于MessageWriter
的类的大型项目中,配置代码将分散在整个应用中。 - 这种实现很难进行单元测试。 应用需使用模拟或存根
MessageWriter
类,而该类不能使用此方法。
依赖关系注入通过以下方式解决了这些问题:
- 使用接口或基类将依赖关系实现抽象化。
- 在服务容器中注册依赖关系。 .NET 提供了一个内置的服务容器 IServiceProvider。 服务通常在应用启动时注册,并追加到 IServiceCollection。 添加所有服务后,可以使用 BuildServiceProvider 创建服务容器。
- 将服务注入到使用它的类的构造函数中。 框架负责创建依赖关系的实例,并在不再需要时将其释放。
例如,IMessageWriter
接口定义 Write
方法:
csharp
namespace DependencyInjection.Example;
public interface IMessageWriter
{
void Write(string message);
}
此接口由具体类型 MessageWriter
实现:
csharp
namespace DependencyInjection.Example;
public class MessageWriter : IMessageWriter
{
public void Write(string message)
{
Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
}
}
示例代码使用具体类型 MessageWriter
注册 IMessageWriter
服务。 AddSingleton 方法使用单一实例生存期(应用的生存期)注册服务。 本文后面将介绍服务生存期。
csharp
using DependencyInjection.Example;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddSingleton<IMessageWriter, MessageWriter>();
using IHost host = builder.Build();
host.Run();
在上面的代码中,示例应用:
- 创建主机应用生成器实例。
- 通过注册以下内容来配置服务:
Worker
作为托管服务。 有关详细信息,请参阅 .NET 中的辅助角色服务。IMessageWriter
接口作为具有MessageWriter
类相应实现的单一实例服务。
- 生成主机并运行它。
主机包含依赖关系注入服务提供程序。 它还包含自动实例化 Worker
并提供相应的 IMessageWriter
实现作为参数所需的所有其他相关服务。
csharp
namespace DependencyInjection.Example;
public sealed class Worker(IMessageWriter messageWriter) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1_000, stoppingToken);
}
}
}
通过使用 DI 模式,辅助角色服务:
- 不使用具体类型
MessageWriter
,只使用实现它的IMessageWriter
接口。 这样可以轻松地更改辅助角色服务使用的实现,而无需修改辅助角色服务。 - 不要创建
MessageWriter
的实例。 该实例由 DI 容器创建。
可以通过使用内置日志记录 API 来改善 IMessageWriter
接口的实现:
csharp
namespace DependencyInjection.Example;
public class LoggingMessageWriter(
ILogger<LoggingMessageWriter> logger) : IMessageWriter
{
public void Write(string message) =>
logger.LogInformation("Info: {Msg}", message);
}
更新的 AddSingleton
方法注册新的 IMessageWriter
实现:
csharp
builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
HostApplicationBuilder (builder
)类型是 Microsoft.Extensions.Hosting
NuGet 包的一部分。
LoggingMessageWriter
依赖于 ILogger,并在构造函数中对其进行请求。 ILogger<TCategoryName>
是 ILogger<TCategoryName>
。
以链式方式使用依赖关系注入并不罕见。 每个请求的依赖关系相应地请求其自己的依赖关系。 容器解析图中的依赖关系并返回完全解析的服务。 必须被解析的依赖关系的集合通常被称为"依赖关系树"、"依赖关系图"或"对象图"。
容器通过利用(泛型)开放类型解析 ILogger<TCategoryName>
,而无需注册每个(泛型)构造类型。
在依赖项注入术语中,服务:
- 通常是向其他对象提供服务的对象,如
IMessageWriter
服务。 - 与 Web 服务无关,尽管服务可能使用 Web 服务。
框架提供可靠的日志记录系统。 编写上述示例中的 IMessageWriter
实现来演示基本的 DI,而不是来实现日志记录。 大多数应用都不需要编写记录器。 下面的代码展示了如何使用默认日志记录,只需要将 Worker
注册为托管服务AddHostedService:
csharp
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger) =>
_logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1_000, stoppingToken);
}
}
}
使用前面的代码时,无需更新 Program.cs,因为框架提供日志记录。
多个构造函数发现规则
当某个类型定义多个构造函数时,服务提供程序具有用于确定要使用哪个构造函数的逻辑。 选择最多参数的构造函数,其中的类型是可 DI 解析的类型。 请考虑以下 C# 示例服务:
csharp
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(ILogger<ExampleService> logger)
{
// omitted for brevity
}
public ExampleService(FooService fooService, BarService barService)
{
// omitted for brevity
}
}
在前面的代码中,假定已添加日志记录,并且可以从服务提供程序解析,但 FooService
和 BarService
类型不可解析。 使用 ILogger<ExampleService>
参数的构造函数用于解析 ExampleService
实例。 即使有定义多个参数的构造函数,FooService
和 BarService
类型也不能进行 DI 解析。
如果发现构造函数时存在歧义,将引发异常。 请考虑以下 C# 示例服务:
csharp
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(ILogger<ExampleService> logger)
{
// omitted for brevity
}
public ExampleService(IOptions<ExampleOptions> options)
{
// omitted for brevity
}
}
警告
具有不明确的可 DI 解析的类型参数的
ExampleService
代码将引发异常。 不要执行此操作,它旨在显示"不明确的可 DI 解析类型"的含义。
在前面的示例中,有三个构造函数。 第一个构造函数是无参数的,不需要服务提供商提供的服务。 假设日志记录和选项都已添加到 DI 容器,并且是可 DI 解析的服务。 当 DI 容器尝试解析 ExampleService
类型时,将引发异常,因为这两个构造函数不明确。
可通过定义一个接受 DI 可解析的类型的构造函数来避免歧义:
csharp
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(
ILogger<ExampleService> logger,
IOptions<ExampleOptions> options)
{
// omitted for brevity
}
}
使用扩展方法注册服务组
Microsoft 扩展使用一种约定来注册一组相关服务。 约定使用单个 Add{GROUP_NAME}
扩展方法来注册该框架功能所需的所有服务。 例如,AddOptions 扩展方法会注册使用选项所需的所有服务。
框架提供的服务
使用任何可用的主机或应用生成器模式时,会应用默认值,并由框架注册服务。 请考虑一些最常用的主机和应用生成器模式:
- Host.CreateDefaultBuilder()
- Host.CreateApplicationBuilder()
- WebHost.CreateDefaultBuilder()
- WebApplication.CreateBuilder()
- WebAssemblyHostBuilder.CreateDefault
- MauiApp.CreateBuilder
从这些 API 中的任何一个创建生成器后, IServiceCollection
具有框架定义的服务,具体取决于主机的配置方式。 对于基于 .NET 模板的应用,该框架会注册数百个服务。
下表列出了框架注册的这些服务的一小部分:
展开表
服务生存期
可以使用以下任一生存期注册服务:
- 暂时
- 作用域
- 单例
下列各部分描述了上述每个生存期。 为每个注册的服务选择适当的生存期。
暂时
暂时生存期服务是每次从服务容器进行请求时创建的。 这种生存期适合轻量级、 无状态的服务。 向 AddTransient 注册暂时性服务。
在处理请求的应用中,在请求结束时会释放暂时服务。
范围内
对于 Web 应用,指定了作用域的生存期指明了每个客户端请求(连接)创建一次服务。 向 AddScoped 注册范围内服务。
在处理请求的应用中,在请求结束时会释放有作用域的服务。
使用 Entity Framework Core 时,默认情况下 AddDbContext 扩展方法使用范围内生存期来注册 DbContext
类型。
备注
不要从单一实例解析限定范围的服务,并小心不要间接地这样做,例如通过暂时性服务。 当处理后续请求时,它可能会导致服务处于不正确的状态。 可以:
- 从范围内或暂时性服务解析单一实例服务。
- 从其他范围内或暂时性服务解析范围内服务。
默认情况下在开发环境中,从具有较长生存期的其他服务解析服务将引发异常。 有关详细信息,请参阅作用域验证。
单例
创建单例生命周期服务的情况如下:
- 在首次请求它们时进行创建;或者
- 在向容器直接提供实现实例时由开发人员进行创建。 很少用到此方法。
来自依赖关系注入容器的服务实现的每一个后续请求都使用同一个实例。 如果应用需要单一实例行为,则允许服务容器管理服务的生存期。 不要实现单一实例设计模式,或提供代码来释放单一实例。 服务永远不应由解析容器服务的代码释放。 如果类型或工厂注册为单一实例,则容器自动释放单一实例。
向 AddSingleton 注册单一实例服务。 单一实例服务必须是线程安全的,并且通常在无状态服务中使用。
在处理请求的应用中,当应用关闭并释放 ServiceProvider 时,会释放单一实例服务。 由于应用关闭之前不释放内存,因此请考虑单一实例服务的内存使用。
服务注册方法
框架提供了适用于特定场景的服务注册扩展方法:
展开表
方法 | 自动 对象 释放 | 多种 实现 | 传递参数 |
---|---|---|---|
Add{LIFETIME}<{SERVICE}, {IMPLEMENTATION}>() 示例: services.AddSingleton<IMyDep, MyDep>(); |
是 | 是 | 否 |
Add{LIFETIME}<{SERVICE}>(sp => new {IMPLEMENTATION}) 示例: services.AddSingleton<IMyDep>(sp => new MyDep()); services.AddSingleton<IMyDep>(sp => new MyDep(99)); |
是 | 是 | 是 |
Add{LIFETIME}<{IMPLEMENTATION}>() 示例: services.AddSingleton<MyDep>(); |
是 | 否 | 否 |
AddSingleton<{SERVICE}>(new {IMPLEMENTATION}) 示例: services.AddSingleton<IMyDep>(new MyDep()); services.AddSingleton<IMyDep>(new MyDep(99)); |
否 | 是 | 是 |
AddSingleton(new {IMPLEMENTATION}) 示例: services.AddSingleton(new MyDep()); services.AddSingleton(new MyDep(99)); |
否 | No | 是 |
要详细了解释放类型,请参阅服务释放部分。
仅使用实现类型注册服务等效于使用相同的实现和服务类型注册该服务。 因此,我们不能使用捕获显式服务类型的方法来注册服务的多个实现。 这些方法可以注册服务的多个实例,但它们都具有相同的实现类型 。
上述任何服务注册方法都可用于注册同一服务类型的多个服务实例。 下面的示例以 IMessageWriter
作为服务类型调用 AddSingleton
两次。 第二次对 AddSingleton
的调用在解析为 IMessageWriter
时替代上一次调用,在通过 IEnumerable<IMessageWriter>
解析多个服务时添加到上一次调用。 通过 IEnumerable<{SERVICE}>
解析服务时,服务按其注册顺序显示。
csharp
using ConsoleDI.IEnumerableExample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
builder.Services.AddSingleton<ExampleService>();
using IHost host = builder.Build();
_ = host.Services.GetService<ExampleService>();
await host.RunAsync();
前面的示例源代码注册了 IMessageWriter
的两个实现。
csharp
using System.Diagnostics;
namespace ConsoleDI.IEnumerableExample;
public sealed class ExampleService
{
public ExampleService(
IMessageWriter messageWriter,
IEnumerable<IMessageWriter> messageWriters)
{
Trace.Assert(messageWriter is LoggingMessageWriter);
var dependencyArray = messageWriters.ToArray();
Trace.Assert(dependencyArray[0] is ConsoleMessageWriter);
Trace.Assert(dependencyArray[1] is LoggingMessageWriter);
}
}
ExampleService
定义两个构造函数参数:一个是 IMessageWriter
,另一个是 IEnumerable<IMessageWriter>
。 第一个 IMessageWriter
是已注册的最后一个实现,而 IEnumerable<IMessageWriter>
表示所有已注册的实现。
框架还提供 TryAdd{LIFETIME}
扩展方法,只有当尚未注册某个实现时,才注册该服务。
在下面的示例中,对 AddSingleton
的调用会将 ConsoleMessageWriter
注册为 IMessageWriter
的实现。 对 TryAddSingleton
的调用没有任何作用,因为 IMessageWriter
已有一个已注册的实现:
csharp
services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.TryAddSingleton<IMessageWriter, LoggingMessageWriter>();
TryAddSingleton
不起作用,因为已添加它并且"try"将失败。 ExampleService
将断言以下内容:
csharp
public class ExampleService
{
public ExampleService(
IMessageWriter messageWriter,
IEnumerable<IMessageWriter> messageWriters)
{
Trace.Assert(messageWriter is ConsoleMessageWriter);
Trace.Assert(messageWriters.Single() is ConsoleMessageWriter);
}
}
有关详细信息,请参阅:
TryAddEnumerable(ServiceDescriptor) 方法仅会在没有同一类型实现的情况下才注册该服务。 多个服务通过 IEnumerable<{SERVICE}>
解析。 注册服务时,如果还没有添加相同类型的实例,就添加一个实例。 库作者使用 TryAddEnumerable
来避免在容器中注册一个实现的多个副本。
在下面的示例中,对 TryAddEnumerable
的第一次调用会将 MessageWriter
注册为 IMessageWriter1
的实现。 第二次调用向 IMessageWriter2
注册 MessageWriter
。 第三次调用没有任何作用,因为 IMessageWriter1
已有一个 MessageWriter
的已注册的实现:
csharp
public interface IMessageWriter1 { }
public interface IMessageWriter2 { }
public class MessageWriter : IMessageWriter1, IMessageWriter2
{
}
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter2, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
服务注册通常与顺序无关,除了注册同一类型的多个实现时。
IServiceCollection
是 ServiceDescriptor 对象的集合。 以下示例演示如何通过创建和添加 ServiceDescriptor
来注册服务:
csharp
string secretKey = Configuration["SecretKey"];
var descriptor = new ServiceDescriptor(
typeof(IMessageWriter),
_ => new DefaultMessageWriter(secretKey),
ServiceLifetime.Transient);
services.Add(descriptor);
内置 Add{LIFETIME}
方法使用同一种方式。 相关示例请参阅 AddScoped 源代码。
构造函数注入行为
服务可使用以下方式来解析:
- IServiceProvider
- ActivatorUtilities:
- 创建未在容器中注册的对象。
- 用于某些框架功能。
构造函数可以接受非依赖关系注入提供的参数,但参数必须分配默认值。
当服务由 IServiceProvider
或 ActivatorUtilities
解析时,构造函数注入需要 public 构造函数。
当服务由 ActivatorUtilities
解析时,构造函数注入要求只存在一个适用的构造函数。 支持构造函数重载,但其参数可以全部通过依赖注入来实现的重载只能存在一个。
作用域验证
如果应用在 Development
环境中运行,并调用CreateApplicatioBuilder以生成主机,默认服务提供程序会执行检查,以确认以下内容:
- 没有从根服务提供程序解析到范围内服务。
- 未将范围内服务注入单一实例。
调用 BuildServiceProvider 时创建根服务提供程序。 在启动提供程序和应用时,根服务提供程序的生存期对应于应用的生存期,并在关闭应用时释放。
有作用域的服务由创建它们的容器释放。 如果范围内服务创建于根容器,则该服务的生存期实际上提升至单一实例,因为根容器只会在应用关闭时将其释放。 验证服务作用域,将在调用 BuildServiceProvider
时收集这类情况。
范围场景
IServiceScopeFactory 始终注册为单一实例,但 IServiceProvider 可能因包含类的生存期而异。 例如,如果从某个范围解析服务,而这些服务中的任意一种采用 IServiceProvider,该服务将是区分范围的实例。
若要在 IHostedService 的实现(例如 BackgroundService)中实现范围服务,请不要通过构造函数注入来注入服务依赖项。 请改为注入 IServiceScopeFactory,创建范围,然后从该范围解析依赖项以使用适当的服务生存期。
csharp
namespace WorkerScope.Example;
public sealed class Worker(
ILogger<Worker> logger,
IServiceScopeFactory serviceScopeFactory)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (IServiceScope scope = serviceScopeFactory.CreateScope())
{
try
{
logger.LogInformation(
"Starting scoped work, provider hash: {hash}.",
scope.ServiceProvider.GetHashCode());
var store = scope.ServiceProvider.GetRequiredService<IObjectStore>();
var next = await store.GetNextAsync();
logger.LogInformation("{next}", next);
var processor = scope.ServiceProvider.GetRequiredService<IObjectProcessor>();
await processor.ProcessAsync(next);
logger.LogInformation("Processing {name}.", next.Name);
var relay = scope.ServiceProvider.GetRequiredService<IObjectRelay>();
await relay.RelayAsync(next);
logger.LogInformation("Processed results have been relayed.");
var marked = await store.MarkAsync(next);
logger.LogInformation("Marked as processed: {next}", marked);
}
finally
{
logger.LogInformation(
"Finished scoped work, provider hash: {hash}.{nl}",
scope.ServiceProvider.GetHashCode(), Environment.NewLine);
}
}
}
}
}
在上述代码中,当应用运行时,后台服务:
- 依赖于 IServiceScopeFactory。
- 创建 IServiceScope 用于解析其他服务。
- 解析区分范围内的服务以供使用。
- 处理要处理的对象,然后对其执行中继操作,最后将其标记为已处理。
在示例源代码中,可以看到 IHostedService 的实现如何从区分范围的服务生存期中获益。
键控服务
从 .NET 8 开始支持基于密钥的服务注册和查找,这意味着可以使用其他密钥注册多个服务,并使用此密钥进行查找。
例如,假设接口 IMessageWriter
有不同的实现:MemoryMessageWriter
和 QueueMessageWriter
。
可以使用支持密钥作为参数的服务注册方法(前面所示)的重载来注册这些服务:
csharp
services.AddKeyedSingleton<IMessageWriter, MemoryMessageWriter>("memory");
services.AddKeyedSingleton<IMessageWriter, QueueMessageWriter>("queue");
key
不限于 string
,只要类型正确地实现了 Equals
,它就可以是所需的任何 object
。
在使用 IMessageWriter
的类的构造函数中,添加 FromKeyedServicesAttribute 以指定要解析的服务的密钥:
csharp
public class ExampleService
{
public ExampleService(
[FromKeyedServices("queue")] IMessageWriter writer)
{
// Omitted for brevity...
}
}
另请参阅
- 在 .NET 中使用依赖关系注入
- 依赖关系注入指南
- ASP.NET Core 中的依赖关系注入
- 用于 DI 应用开发的 NDC 会议模式
- 显式依赖关系原则
- 控制反转容器和依赖关系注入模式 (Martin Fowler)
- 应在 github.com/dotnet/extensions 存储库中创建 DI bug
NuGet包管理
NuGet 是 .NET 生态系统的包管理器,并且是开发人员用来发现并获取 .NET 开放源代码库的主要方法。 NuGet.org(由托管 NuGet 包的 Microsoft 提供的免费服务)是公共 NuGet 包的主要主机,但可以发布到自定义 NuGet 服务,如 MyGet 和 Azure Artifacts。
创建 NuGet 包
NuGet 包 (*.nupkg
) 是一个 zip 文件,其中包含 .NET 程序集和关联的元数据。
创建 NuGet 包有两种主要方式。 较新的推荐方式是从 SDK 样式项目(其内容以 <Project Sdk="Microsoft.NET.Sdk">
开头的项目文件)创建包。 程序集和目标会自动添加到包,剩余元数据会添加到 MSBuild 文件,如包名称和版本号。 使用 dotnet pack
命令编译会输出 *.nupkg
文件,而不是程序集。
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<AssemblyName>Contoso.Api</AssemblyName>
<PackageVersion>1.1.0</PackageVersion>
<Authors>John Doe</Authors>
</PropertyGroup>
</Project>
创建 NuGet 包的较旧方式是使用 *.nuspec
文件和 nuget.exe
命令行工具。 nuspec 文件为你提供更好的控制,但必须仔细指定要包含在最终 NuGet 包中的程序集和目标。 很容易犯错或很容易忘记在发生更改时更新 nuspec。 nuspec 的优点是可以将其用于创建尚不支持 SDK 样式项目文件的框架的 NuGet 包。
✔️ 请考虑使用 SDK 样式项目文件创建 NuGet 包。
包依赖项
依赖项一文详细介绍了 NuGet 包依赖项。
重要的 NuGet 包元数据
NuGet 包支持多个元数据属性。 下表包含 NuGet.org 上的每个包应提供的核心元数据:
MSBuild 属性名称 | Nuspec 名称 | 描述 |
---|---|---|
PackageId |
id |
包标识符。 如果标识符的前缀满足条件,则可以保留该前缀。 |
PackageVersion |
version |
NuGet 包版本。 有关详细信息,请参阅NuGet 包版本。 |
Title |
title |
明了易用的包标题。 默认为 PackageId 。 |
Description |
description |
UI 中显示的包的详细说明。 |
Authors |
authors |
包创建者的逗号分隔列表,与 nuget.org 上的配置文件名称一致。 |
PackageTags |
tags |
描述包的标记和关键字的空格或分号分隔列表。 搜索包时使用标记。 |
PackageIcon |
icon |
包中要用作包图标的图像的路径。 详细了解 元数据。 |
PackageProjectUrl |
projectUrl |
项目主页或源存储库的 URL。 |
PackageLicenseExpression |
license |
项目许可证的SPDX 标识符。 只有获得 OSI 和 FSF 批准的许可证才能使用标识符。 其他许可证应使用 PackageLicenseFile 。 详细了解 元数据。 |
重要
无许可证的项目默认为 exclusive copyright(独占版权所有),从而无法供其他人使用。
✔️ 请考虑选择带有满足 NuGet 前缀预留条件的前缀的 NuGet 包名称。
✔️ 请使用指向包图标的 HTTPS href。
启用 HTTPS 运行并显示非 HTTPS 图像的 NuGet.org 等网站将创建混合内容警告。
✔️ 请使用属于 64x64 并具有透明背景的包图标图像以获得最佳查看结果。
✔️ 请考虑设置源链接以将源代码管理元数据添加到程序集和 NuGet 包中。
源链接会自动将
RepositoryUrl
和RepositoryType
元数据添加到 NuGet 包中。 源链接还会添加用于构建包的确切源代码的相关信息。 例如,从 Git 存储库创建的包将添加提交哈希作为元数据。
预发行包
具有版本后缀的 NuGet 包被视为预发行版。 默认情况下,NuGet 包管理器 UI 显示稳定版本,除非用户选择预发行包,使预发行包适用于受限的用户测试。
xml
<PackageVersion>1.0.1-beta1</PackageVersion>
备注
稳定版包不能依赖于预发行包。 必须创建自己的预发行包或依赖于较旧的稳定版本。
✔️ 请在测试、预览或试用预发行包后进行发布。
✔️ 请在稳定版包就绪后进行发布,以便其他稳定版包可以引用它。
符号包
符号文件 (*.pdb
) 由 .NET 编译器与程序集一起生成。 符号文件将执行位置映射到原始源代码,以便可以逐行执行源代码(因为它使用调试程序运行)。 NuGet 支持生成单独的符号包 ()(包含符号文件)以及主包(包含 .NET 程序集)。 符号包的理念是它们托管在符号服务器上并仅由 Visual Studio 等工具按需下载。
NuGet.org 托管了自己的符号服务器存储库。 开发人员可以通过向其在 Visual Studio 中的符号源添加 https://symbols.nuget.org/download/symbols
,来使用发布到 NuGet.org 符号服务器的符号。
重要
NuGet.org 符号服务器仅支持由 SDK 样式项目创建的新的可移植符号文件 (
*.pdb
)。若要在调试 .NET 库时使用 NuGet.org 符号服务器,开发人员必须安装有 Visual Studio 2017 版本 15.9 或更高版本。
创建符号包的另一种方法是在主 NuGet 包中嵌入符号文件。 主 NuGet 包将变大,但嵌入的符号文件意味着开发人员不需要配置 NuGet.org 符号服务器。 如果使用 SDK 样式项目生成 NuGet 包,则可以通过设置 AllowedOutputExtensionsInPackageBuildOutputFolder
属性来嵌入符号文件:
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Include symbol files (*.pdb) in the built .nupkg -->
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
</PropertyGroup>
</Project>
嵌入式符号文件的缺点是,对于使用 SDK 样式项目编译的 .NET 库,它们会将包的大小增加约 30%。 如果要考虑包大小,应改成在符号包中发布符号。
✔️ 请考虑将符号作为符号包 (*.snupkg
) 发布到 NuGet.org
符号包 (
*.snupkg
) 为开发人员提供了良好的按需调试体验,而不会使主程序包大小膨胀,也不会影响那些不打算调试 NuGet 包的用户的还原性能。需要注意的是,用户可能需要在其 IDE 中查找和配置 NuGet 符号服务器(作为一次性设置)来获取符号文件。 Visual Studio 2019 版本 16.1 将 NuGet.org 的符号服务器添加到了默认符号服务器列表中。
异常处理
应用程序必须能够以一致的方式处理执行期间发生的错误。 .NET 提供一种以统一方式向应用程序报错的模型:.NET 操作通过引发异常来指示故障。
异常
异常是执行程序遇到的所有错误条件或意外行为。 异常可能由你的代码或调用的代码(如共享库)中的错误、不可用的操作系统资源、运行时遇到的意外情况(如无法验证的代码)等引发。 应用程序可从这些情况中的一些中恢复,但无法从其他情况中恢复。 尽管可以从大多数应用程序异常中恢复,但不能从大多数运行时异常中恢复。
在 .NET中,异常是从 System.Exception 类继承的对象。 异常引发自发生问题的代码区域。 异常在堆栈中向上传递,直到应用程序对其进行处理或者程序终止。
异常与传统的错误处理方法
传统上,语言的错误处理模型依赖语言检测错误和针对错误查找其处理程序的独特方式,或者依赖操作系统提供的错误处理机制。 .NET 实现异常处理的方式有以下优点:
- 引发和处理异常的方式与 .NET 编程语言的相同。
- 处理异常不需要任何特定的语言语法,但允许每种语言定义自己的语法。
- 可跨进程,甚至跨计算机边界引发异常。
- 可向应用程序添加异常处理代码以提高程序的可靠性。
异常相较于其他错误通知方法(如返回代码)具有多种优势。 故障不会被忽略掉,因为如果引发了异常且未得到解决,运行时会终止应用程序。 因为代码未能检查出是否存在故障返回代码,所以无效值不会继续在系统中传播。
常见异常
下表列出了一些常见的异常,以及会引发这些异常的原因的示例。
异常类型 | 描述 | 示例 |
---|---|---|
Exception | 所有异常的基类。 | 无(使用此异常的派生类)。 |
IndexOutOfRangeException | 仅当错误地对数组进行索引时,才由运行时引发。 | 在数组的有效范围外对数组进行索引:arr[arr.Length+1] |
NullReferenceException | 仅当引用 null 对象时,才由运行时引发。 | object o = null; o.ToString(); |
InvalidOperationException | 当处于无效状态时,由方法引发。 | 从基础集合删除项后调用 Enumerator.MoveNext() 。 |
ArgumentException | 所有自变量异常的基类。 | 无(使用此异常的派生类)。 |
ArgumentNullException | 由不允许参数为 null 的方法引发。 | String s = null; "Calculate".IndexOf(s); |
ArgumentOutOfRangeException | 由验证自变量是否位于给定范围内的方法引发。 | String s = "string"; s.Substring(s.Length+1); |
请参阅
- 异常类和属性
- 如何:使用 Try-Catch 块捕获异常
- 如何:在 Catch 块中使用特定异常
- 如何:显式抛出异常
- 如何:创建用户定义异常
- 使用用户筛选的异常处理程序
- 如何:使用 Finally 块
- 处理 COM 互操作异常
- 与异常有关的最佳做法
- 每个开发人员都需要了解的有关运行时异常方面的内容
结构类型(struct结构体)
结构类型("structure type"或"struct type")是一种可封装数据和相关功能的值类型 。 使用 struct
关键字定义结构类型:
csharp
public struct Coords
{
public Coords(double x, double y)
{
X = x;
Y = y;
}
public double X { get; }
public double Y { get; }
public override string ToString() => $"({X}, {Y})";
}
有关 ref struct
和 readonly ref struct
类型的信息,请参阅参考结构类型一文。
结构类型具有值语义 。 也就是说,结构类型的变量包含类型的实例。 默认情况下,在分配中,通过将参数传递给方法并返回方法结果来复制变量值。 对于结构类型变量,将复制该类型的实例。 有关更多信息,请参阅值类型。
通常,可以使用结构类型来设计以数据为中心的较小类型,这些类型只有很少的行为或没有行为。 例如,.NET 使用结构类型来表示数字(整数和实数)、布尔值、Unicode 字符以及时间实例。 如果侧重于类型的行为,请考虑定义一个类。 类类型具有引用语义 。 也就是说,类类型的变量包含的是对类型的实例的引用,而不是实例本身。
由于结构类型具有值语义,因此建议定义不可变的结构类型。
结构体的使用场景
- 电脑的内存分为堆内存和栈内存堆内存空间远大于栈内存但是栈内存执行效率却高于堆内存
- 引用类型保存在堆内存中,而值类型则保存在栈内存
- class对象是引l用类型,保存在堆内存中,执行效率比较低
- struct是值类型,保存在栈内存中,运行效率高
class的使用场景
- 抽象的概念、或者需要多个层级来表现对象关系
- 适用于结构复杂的数据
结构体的特点
- 可带有方法、字段、索引I、属性、运算符方法和事件
- 结构不能定义无参的默认构造方法
- 结构可实现接口,但它不能继承,也不能被继承
- 实例化可以使用new(),但也可以不用new()
readonly
结构
可以使用 readonly
修饰符来声明结构类型为不可变。 readonly
结构的所有数据成员都必须是只读的,如下所示:
- 任何字段声明都必须具有
readonly
修饰符 - 任何属性(包括自动实现的属性)都必须是只读的或仅
init
。
这样可以保证 readonly
结构的成员不会修改该结构的状态。 这意味着除构造函数外的其他实例成员是隐式 readonly
。
备注
在
readonly
结构中,可变引用类型的数据成员仍可改变其自身的状态。 例如,不能替换 List 实例,但可以向其中添加新元素。
下面的代码使用 init-only 属性资源库定义 readonly
结构:
csharp
public readonly struct Coords
{
public Coords(double x, double y)
{
X = x;
Y = y;
}
public double X { get; init; }
public double Y { get; init; }
public override string ToString() => $"({X}, {Y})";
}
readonly
实例成员
还可以使用 readonly
修饰符来声明实例成员不会修改结构的状态。 如果不能将整个结构类型声明为 readonly
,可使用 readonly
修饰符标记不会修改结构状态的实例成员。
在 readonly
实例成员内,不能分配到结构的实例字段。 但是,readonly
成员可以调用非 readonly
成员。 在这种情况下,编译器将创建结构实例的副本,并调用该副本上的非 readonly
成员。 因此,不会修改原始结构实例。
通常,将 readonly
修饰符应用于以下类型的实例成员:
-
方法:
csharppublic readonly double Sum() { return X + Y; }
还可以将
readonly
修饰符应用于可替代在 System.Object 中声明的方法的方法:csharppublic readonly override string ToString() => $"({X}, {Y})";
-
属性和索引器:
csharpprivate int counter; public int Counter { readonly get => counter; set => counter = value; }
如果需要将
readonly
修饰符应用于属性或索引器的两个访问器,请在属性或索引器的声明中应用它。备注
编译器会将自动实现的属性的
get
访问器声明为readonly
,而不管属性声明中是否存在readonly
修饰符。可以将
readonly
修饰符应用于具有init
访问器的属性或索引器:csharppublic readonly double X { get; init; }
可以将 readonly
修饰符应用于结构类型的静态字段,但不能应用于任何其他静态成员,例如属性或方法。
编译器可以使用 readonly
修饰符进行性能优化。 有关详细信息,请参阅避免分配。
非破坏性变化
从 C# 10 开始,可以使用 with
表达式来生成修改了指定属性和字段的结构类型实例的副本。 使用对象初始值设定项语法来指定要修改的成员及其新值,如以下示例所示:
csharp
public readonly struct Coords
{
public Coords(double x, double y)
{
X = x;
Y = y;
}
public double X { get; init; }
public double Y { get; init; }
public override string ToString() => $"({X}, {Y})";
}
public static void Main()
{
var p1 = new Coords(0, 0);
Console.WriteLine(p1); // output: (0, 0)
var p2 = p1 with { X = 3 };
Console.WriteLine(p2); // output: (3, 0)
var p3 = p1 with { X = 1, Y = 4 };
Console.WriteLine(p3); // output: (1, 4)
}
record
结构
从 C# 10 开始,可定义记录结构类型。 记录类型提供用于封装数据的内置功能。 可同时定义 record struct
和 readonly record struct
类型。 记录结构不能是 ref struct
。 有关详细信息和示例,请参阅记录。
内联数组
从 C# 12 开始,可以将内联数组 声明为 struct
类型:
csharp
[System.Runtime.CompilerServices.InlineArray(10)]
public struct CharBuffer
{
private char _firstElement;
}
内联数组是包含相同类型的 N 个元素的连续块的结构。 它是一个安全代码,等效于仅在不安全代码中可用的固定缓冲区声明。 内联数组是具有以下特征的 struct
:
- 它包含单个字段。
- 结构未指定显式布局。
此外,编译器还会验证 System.Runtime.CompilerServices.InlineArrayAttribute 属性:
- 必须大于零 (
> 0
)。 - 目标类型必须是结构。
在大多数情况下,可以像访问数组一样访问内联数组,以读取和写入值。 此外,还可以使用范围和索引运算符。
对单个字段的类型有最低限制。 它不能是指针类型,但可以是任何引用类型或任何值类型。 几乎可以将内联数组与任何 C# 数据结构一起使用。
内联数组是一种高级语言功能。 它们适用于高性能方案,在这些方案中,内联的连续元素块比其他替代数据结构速度更快。 可以从功能规范中了解有关内联数组的详细信息
结构初始化和默认值
struct
类型的变量直接包含该 struct
类型的数据。 这会让未初始化的 struct
(具有其默认值)和已初始化的 struct
(通过构造值来存储一组值)之间存在区别。 例如,考虑下面的代码:
csharp
public readonly struct Measurement
{
public Measurement()
{
Value = double.NaN;
Description = "Undefined";
}
public Measurement(double value, string description)
{
Value = value;
Description = description;
}
public double Value { get; init; }
public string Description { get; init; }
public override string ToString() => $"{Value} ({Description})";
}
public static void Main()
{
var m1 = new Measurement();
Console.WriteLine(m1); // output: NaN (Undefined)
var m2 = default(Measurement);
Console.WriteLine(m2); // output: 0 ()
var ms = new Measurement[2];
Console.WriteLine(string.Join(", ", ms)); // output: 0 (), 0 ()
}
如前面的示例所示,默认值表达式忽略了无参数构造函数,并生成了结构类型的默认值。 结构类型数组实例化还忽略无参数构造函数并生成使用结构类型的默认值填充的数组。
你看到默认值的最常见情况是在数组中或内部存储包含变量块的其他集合中。 以下示例创建了一个由 30 个 TemperatureRange
结构组成的数组,每个结构都具有默认值:
csharp
// All elements have default values of 0:
TemperatureRange[] lastMonth = new TemperatureRange[30];
结构的所有成员字段在创建时必须进行明确指定,因为 struct
类型直接存储其数据。 结构的 default
值已将所有字段明确指定为 0。 调用构造函数时,必须明确指定所有字段。 可以使用以下机制初始化字段:
- 可以将字段初始化表达式添加到任何字段或自动实现的属性。
- 可以在构造函数主体中初始化任何字段或自动属性。
从 C# 11 开始,如果你没有初始化结构中的所有字段,编译器会将代码添加到将这些字段初始化为默认值的构造函数中。 编译器执行其常用的明确指定分析。 在指定之前访问的任何字段,或者当构造函数完成执行时未明确指定的字段,会在构造函数主体执行之前被指定其默认值。 如果在指定所有字段之前访问 this
,则结构会在构造函数主体执行之前初始化为默认值。
csharp
public readonly struct Measurement
{
public Measurement(double value)
{
Value = value;
}
public Measurement(double value, string description)
{
Value = value;
Description = description;
}
public Measurement(string description)
{
Description = description;
}
public double Value { get; init; }
public string Description { get; init; } = "Ordinary measurement";
public override string ToString() => $"{Value} ({Description})";
}
public static void Main()
{
var m1 = new Measurement(5);
Console.WriteLine(m1); // output: 5 (Ordinary measurement)
var m2 = new Measurement();
Console.WriteLine(m2); // output: 0 ()
var m3 = default(Measurement);
Console.WriteLine(m3); // output: 0 ()
}
每个 struct
都具有一个 public
无参数构造函数。 如果要编写无参数构造函数,它必须是公共构造函数。 如果结构声明了任何字段初始值设定项,就必须显式声明一个构造函数。 该构造函数不必是无参数的。 如果结构声明了字段初始值设定项,但没有构造函数,编译器将报告错误。 任何显式声明的构造函数(有参数或无参数)都会执行该结构的所有字段初始值设定项。 没有字段初始值设定项或构造函数的赋值的所有字段均设置为默认值。 有关详细信息,请参阅无参数结构构造函数功能建议说明。
从 C# 12 开始,struct
类型可以将主构造函数定义为其声明的一部分。 主要构造函数为构造函数参数提供了简洁的语法,可在该结构的任何成员声明中的整个 struct
正文中使用。
如果结构类型的所有实例字段都是可访问的,则还可以在不使用 new
运算符的情况下对其进行实例化。 在这种情况下,在首次使用实例之前必须初始化所有实例字段。 下面的示例演示如何执行此操作:
csharp
public static class StructWithoutNew
{
public struct Coords
{
public double x;
public double y;
}
public static void Main()
{
Coords p;
p.x = 3;
p.y = 4;
Console.WriteLine($"({p.x}, {p.y})"); // output: (3, 4)
}
}
在处理内置值类型的情况下,请使用相应的文本来指定类型的值。
结构类型的设计限制
结构具有类类型的大部分功能。 存在一些异常情况,在较新版本中也删除了一些异常:
按引用传递结构类型变量
将结构类型变量作为参数传递给方法或从方法返回结构类型值时,将复制结构类型的整个实例。 通过值传递可能会影响高性能方案中涉及大型结构类型的代码的性能。 通过按引用传递结构类型变量,可以避免值复制操作。 使用 ref
、out
、in
或 ref readonly
方法参数修饰符,指示必须按引用传递某个参数。 使用 ref 返回值按引用返回方法结果。 有关详细信息,请参阅避免分配。
struct 约束
你还可在 struct
约束中使用 struct
关键字,来指定类型参数为不可为 null 的值类型。 结构类型和枚举类型都满足 struct
约束。
转换
对于任何结构类型(ref struct
类型除外),都存在与 System.ValueType 和 System.Object 类型之间的装箱和取消装箱相互转换。 还存在结构类型和它所实现的任何接口之间的装箱和取消装箱转换。
枚举
枚举类型 是由基础整型数值类型的一组命名常量定义的值类型。 若要定义枚举类型,请使用 enum
关键字并指定枚举成员 的名称:
csharp
enum Season
{
Spring,
Summer,
Autumn,
Winter
}
默认情况下,枚举成员的关联常数值为类型 int
;它们从零开始,并按定义文本顺序递增 1。 可以显式指定任何其他整数数值类型作为枚举类型的基础类型。 还可以显式指定关联的常数值,如下面的示例所示:
csharp
enum ErrorCode : ushort
{
None = 0,
Unknown = 1,
ConnectionLost = 100,
OutlierReading = 200
}
不能在枚举类型的定义内定义方法。 若要向枚举类型添加功能,请创建扩展方法。
枚举类型 E
的默认值是由表达式 (E)0
生成的值,即使零没有相应的枚举成员也是如此。
可以使用枚举类型,通过一组互斥值或选项组合来表示选项。 若要表示选项组合,请将枚举类型定义为位标志。
作为位标志的枚举类型
如果希望枚举类型表示选项组合,请为这些选项定义枚举成员,以便单个选项成为位字段。 也就是说,这些枚举成员的关联值应该是 2 的幂。 然后,可以使用按位逻辑运算符 |
或 &
分别合并选项或交叉组合选项。 若要指示枚举类型声明位字段,请对其应用 Flags 属性。 如下面的示例所示,还可以在枚举类型的定义中包含一些典型组合。
csharp
[Flags]
public enum Days
{
None = 0b_0000_0000, // 0
Monday = 0b_0000_0001, // 1
Tuesday = 0b_0000_0010, // 2
Wednesday = 0b_0000_0100, // 4
Thursday = 0b_0000_1000, // 8
Friday = 0b_0001_0000, // 16
Saturday = 0b_0010_0000, // 32
Sunday = 0b_0100_0000, // 64
Weekend = Saturday | Sunday
}
public class FlagsEnumExample
{
public static void Main()
{
Days meetingDays = Days.Monday | Days.Wednesday | Days.Friday;
Console.WriteLine(meetingDays);
// Output:
// Monday, Wednesday, Friday
Days workingFromHomeDays = Days.Thursday | Days.Friday;
Console.WriteLine($"Join a meeting by phone on {meetingDays & workingFromHomeDays}");
// Output:
// Join a meeting by phone on Friday
bool isMeetingOnTuesday = (meetingDays & Days.Tuesday) == Days.Tuesday;
Console.WriteLine($"Is there a meeting on Tuesday: {isMeetingOnTuesday}");
// Output:
// Is there a meeting on Tuesday: False
var a = (Days)37;
Console.WriteLine(a);
// Output:
// Monday, Wednesday, Saturday
}
}
有关详细信息和示例,请参阅 System.FlagsAttribute API 参考页和 System.Enum API 参考页的非独占成员和 Flags 属性部分。
System.Enum 类型和枚举约束
System.Enum 类型是所有枚举类型的抽象基类。 它提供多种方法来获取有关枚举类型及其值的信息。 有关更多信息和示例,请参阅 System.Enum API 参考页。
可在基类约束中使用 System.Enum
(称为枚举约束),以指定类型参数为枚举类型。 所有枚举类型也都满足 struct
约束,此约束用于指定类型参数为不可为 null 的值类型。
转换
对于任何枚举类型,枚举类型与其基础整型类型之间存在显式转换。 如果将枚举值转换为其基础类型,则结果为枚举成员的关联整数值。
csharp
public enum Season
{
Spring,
Summer,
Autumn,
Winter
}
public class EnumConversionExample
{
public static void Main()
{
Season a = Season.Autumn;
Console.WriteLine($"Integral value of {a} is {(int)a}"); // output: Integral value of Autumn is 2
var b = (Season)1;
Console.WriteLine(b); // output: Summer
var c = (Season)4;
Console.WriteLine(c); // output: 4
}
}
使用 Enum.IsDefined 方法来确定枚举类型是否包含具有特定关联值的枚举成员。
对于任何枚举类型,都存在分别与 System.Enum 类型的装箱和取消装箱相互转换。
C# 语言规范
有关更多信息,请参阅 C# 语言规范的以下部分:
泛型
借助泛型,你可以根据要处理的精确数据类型定制方法、类、结构或接口。 例如,不使用允许键和值为任意类型的 Hashtable 类,而使用 Dictionary 泛型类并指定允许的密钥和值类型。 泛型的优点包括:代码的可重用性增加,类型安全性提高。
定义和使用泛型
泛型是为所存储或使用的一个或多个类型具有占位符(类型形参)的类、结构、接口和方法。 泛型集合类可以将类型形参用作其存储的对象类型的占位符;类型形参呈现为其字段的类型和其方法的参数类型。 泛型方法可将其类型形参用作其返回值的类型或用作其形参之一的类型。 以下代码举例说明了一个简单的泛型类定义。
csharp
public class Generic<T>
{
public T Field;
}
创建泛型类的实例时,指定用于替代类型形参的实际类型。 在类型形参出现的每一处位置用选定的类型进行替代,这会建立一个被称为构造泛型类的新泛型类。 你将得到根据你选择的类型而定制的类型安全类,如以下代码所示。
csharp
public static void Main()
{
Generic<string> g = new Generic<string>();
g.Field = "A string";
//...
Console.WriteLine("Generic.Field = \"{0}\"", g.Field);
Console.WriteLine("Generic.Field.GetType() = {0}", g.Field.GetType().FullName);
}
泛型术语
介绍 .NET 中的泛型需要用到以下术语:
- 泛型类型定义 是用作模板的类、结构或接口声明,带有可包含或使用的类型的占位符。 例如, System.Collections.Generic.Dictionary 类可以包含两种类型:密钥和值。 由于泛型类型定义只是一个模板,所以你无法创建作为泛型类型定义的类、结构或接口的实例。
- 泛型类型参数 (或类型参数 )是泛型类型或方法定义中的占位符。 System.Collections.Generic.Dictionary 泛型类型具有两个类型形参
TKey
和TValue
,它们分别代表密钥和值的类型。 - 构造泛型类型 (或 构造类型)是为泛型类型定义的泛型类型形参指定类型的结果。
- 泛型类型实参 是被泛型类型形参所替代的任何类型。
- 常见术语泛型类型包括构造类型和泛型类型定义。
- 借助泛型类型参数的协变 和逆变 ,可以使用类型自变量的派生程度比目标构造类型更高(协变)或更低(逆变)的构造泛型类型。 协变和逆变统称为"变体" 。 有关详细信息,请参阅协变和逆变。
- 约束 是对泛型类型参数的限制。 例如,你可能会将一个类型形参限制为实现 System.Collections.Generic.IComparer 泛型接口的类型,以确保可对该类型的实例进行排序。 此外,你还可以将类型形参限制为具有特定基类、具有无参数构造函数或作为引用类型或值类型的类型。 泛型类型的用户不能替换不满足约束条件的类型实参。
- 泛型方法定义 是具有两个形参列表的方法:泛型类型形参列表和形参列表。 类型形参可作为返回类型或形参类型出现,如以下代码所示。
csharp
T Generic<T>(T arg)
{
T temp = arg;
//...
return temp;
}
泛型方法可出现在泛型或非泛型类型中。 值得注意的是,方法不会仅因为它属于泛型类型或甚至因为它有类型为封闭类型泛型参数的形参而成为泛型方法。 只有当方法有属于自己的类型形参列表时才是泛型方法。 在以下代码中,只有方法 G
是泛型方法。
csharp
class A
{
T G<T>(T arg)
{
T temp = arg;
//...
return temp;
}
}
class Generic<T>
{
T M(T arg)
{
T temp = arg;
//...
return temp;
}
}
泛型的利与弊
使用泛型集合和委托有很多好处:
-
类型安全。 泛型将类型安全的负担从你那里转移到编译器。 没有必要编写代码来测试正确的数据类型,因为它会在编译时强制执行。 降低了强制类型转换的必要性和运行时错误的可能性。
-
代码更少且可以更轻松地重用代码。 无需从基类型继承,无需重写成员。 例如,可立即使用 LinkedList 。 例如,你可以使用下列变量声明来创建字符串的链接列表:
csharpLinkedList<string> llist = new LinkedList<string>();
-
性能更好。 泛型集合类型通常能更好地存储和操作值类型,因为无需对值类型进行装箱。
-
泛型委托可以在无需创建多个委托类的情况下进行类型安全的回调。 例如, Predicate 泛型委托允许你创建一种为特定类型实现你自己的搜索标准的方法并将你的方法与 Array 类型比如 Find、 FindLast和 FindAll方法一起使用。
-
泛型简化动态生成的代码。 使用具有动态生成的代码的泛型时,无需生成类型。 这会增加方案数量,在这些方案中你可以使用轻量动态方法而非生成整个程序集。 有关详细信息,请参阅如何:定义和执行动态方法和 DynamicMethod。
以下是泛型的一些局限:
-
泛型类型可从多数基类中派生,如 MarshalByRefObject (约束可用于要求泛型类型形参派生自诸如 MarshalByRefObject的基类)。 不过,.NET 不支持上下文绑定的泛型类型。 泛型类型可派生自 ContextBoundObject,但尝试创建该类型实例会导致 TypeLoadException。
-
枚举不能具有泛型类型形参。 枚举偶尔可为泛型(例如,因为它嵌套在被定义使用 Visual Basic、C# 或 C++ 的泛型类型中)。 有关详细信息,请参阅 "常规类型系统"中的"枚举"。
-
轻量动态方法不能是泛型。
-
在 Visual Basic、C# 和 C++ 中,包含在泛型类型中的嵌套类型不能被实例化,除非已将类型分配给所有封闭类型的类型形参。 另一种说法是:在反射中,定义使用这些语言的嵌套类型包括其所有封闭类型的类型形参。 这使封闭类型的类型形参可在嵌套类型的成员定义中使用。 有关详细信息,请参阅 MakeGenericType中的"嵌套类型"。
备注
通过在动态程序集中触发代码或通过使用 Ilasm.exe (IL 汇编程序) 定义的嵌套类型不需要包括其封闭类型的类型参数;然而,如果不包括,类型参数就不会在嵌套类的范围内。
有关详细信息,请参阅 MakeGenericType中的"嵌套类型"。
类库和语言支持
.NET 在以下命名空间中提供了大量泛型集合类:
- System.Collections.Generic 命名空间包含 .NET 提供的大部分泛型集合类型(如 List 和 Dictionary 泛型类)。
- System.Collections.ObjectModel 命名空间包含向类用户公开对象模型的其他泛型集合类型(如 ReadOnlyCollection 泛型类)。
System 命名空间提供实现排序和等同性比较的泛型接口,还提供事件处理程序、转换和搜索谓词的泛型委托类型。
已将对泛型的支持添加到: System.Reflection 命名空间(以检查泛型类型和泛型方法)、 System.Reflection.Emit (以发出包含泛型类型和方法的动态程序集)和 System.CodeDom (以生成包括泛型的源图)。
公共语言运行时提供了新的操作码和前缀来支持 Microsoft 中间语言 (MSIL) 中的泛型类型,包括 Stelem、 Ldelem、 Unbox_Any、 Constrained和 Readonly。
Visual C++、C# 和 Visual Basic 都对定义和使用泛型提供完全支持。 有关语言支持的详细信息,请参阅 Visual Basic 中的泛型类型、泛型简介和 Visual C++ 中的泛型概述。
嵌套类型和泛型
嵌套在泛型类型中的类型可取决于封闭泛型类型的类型参数。 公共语言运行时将嵌套类型看作泛型,即使它们不具有自己的泛型类型形参。 创建嵌套类型的实例时,必须指定所有封闭泛型类型的类型实参。
相关主题
Title | 描述 |
---|---|
.NET 中的泛型集合 | 介绍了 .NET 中的泛型集合类和其他泛型类型。 |
用于控制数组和列表的泛型委托 | 描述用于转换、搜索谓词以及要对数组或集合中的元素执行的操作的泛型委托。 |
泛型接口 | 描述跨泛型类型系列提供通用功能的泛型接口。 |
协变和逆变 | 描述泛型类型实参中的协变和逆变。 |
常用的集合类型 | 总结了 .NET 中集合类型(包括泛型类型)的特征和使用方案。 |
何时使用泛型集合 | 描述用于确定何时使用泛型集合类型的一般规则。 |
如何:使用反射发出定义泛型类型 | 解释如何生成包括泛型类型和方法的动态程序集。 |
Generic Types in Visual Basic | 为 Visual Basic 用户描述泛型功能,包括有关使用和定义泛型类型的帮助主题。 |
泛型介绍 | 为 C# 用户概述定义和使用泛型类型。 |
Visual C++ 中的泛型概述 | 为 C++ 用户描述泛型功能,包括泛型和模板之间的差异。 |
参考
System.Collections.ObjectModel
System.Reflection.Emit.OpCodes
where(泛型类型约束)
泛型定义中的 where
子句指定对用作泛型类型、方法、委托或本地函数中类型参数的参数类型的约束。 约束可指定接口、基类或要求泛型类型为引用、值或非托管类型。 约束声明类型参数必须具有的功能,并且约束必须位于任何声明的基类或实现的接口之后。
例如,可以声明一个泛型类 AGenericClass
,以使类型参数 T
实现 IComparable 接口:
csharp
public class AGenericClass<T> where T : IComparable<T> { }
备注
有关查询表达式中的 where 子句的详细信息,请参阅 where 子句。
where
子句还可包括基类约束。 基类约束表明用作该泛型类型的类型参数的类型具有指定的类作为基类(或者是该基类)。 该基类约束一经使用,就必须出现在该类型参数的所有其他约束之前。 某些类型不允许作为基类约束:Object、Array 和 ValueType。 以下示例显示现可指定为基类的类型:
csharp
public class UsingEnum<T> where T : System.Enum { }
public class UsingDelegate<T> where T : System.Delegate { }
public class Multicaster<T> where T : System.MulticastDelegate { }
在可为 null 的上下文中,将强制执行基类类型的为 null 性。 如果基类不可为 null(例如 Base
),则类型参数必须不可为 null。 如果基类可为 null(例如 Base?
),则类型参数可以是可为 null 或不可为 null 的引用类型。 当基类不可为 null 时,如果类型参数是可为 null 的引用类型,编译器将发出警告。
where
子句可指定类型为 class
或 struct
。 struct
约束不再需要指定 System.ValueType
的基类约束。 System.ValueType
类型可能不用作基类约束。 以下示例显示 class
和 struct
约束:
csharp
class MyClass<T, U>
where T : class
where U : struct
{ }
在可为 null 的上下文中,class
约束要求类型是不可为 null 的引用类型。 若要允许可为 null 的引用类型,请使用 class?
约束,该约束允许可为 null 和不可为 null 的引用类型。
where
子句可能包含 notnull
约束。 notnull
约束将类型参数限制为不可为 null 的类型。 该类型可以是值类型,也可以是不可为 null 的引用类型。 对于在 nullable enable
上下文中编译的代码,可以使用 notnull
约束。 与其他约束不同,如果类型参数违反 notnull
约束,编译器会生成警告而不是错误。 警告仅在 nullable enable
上下文中生成。
添加可为空引用类型可能会在泛型方法的 T?
含义中产生歧义。 如果 T
是 struct
,则 T?
与 System.Nullable 相同。 但是,如果 T
是引用类型,则 T?
表示 null
是有效值。 出现歧义的原因是,重写方法不能包含约束。 新 default
约束解决了这种歧义。 当基类或接口声明一个方法的两个重载(一个指定 struct
约束,另一个未应用 struct
或 class
约束)时,将添加该约束:
csharp
public abstract class B
{
public void M<T>(T? item) where T : struct { }
public abstract void M<T>(T? item);
}
使用 default
约束来指定派生类在派生类中没有约束的情况下重写方法,或指定显式接口实现。 此做法仅对重写基方法的方法或显式接口实现有效:
csharp
public class D : B
{
// Without the "default" constraint, the compiler tries to override the first method in B
public override void M<T>(T? item) where T : default { }
}
重要
包含
notnull
约束的泛型声明可以在可为 null 的不明显上下文中使用,但编译器不会强制执行约束。
csharp
#nullable enable
class NotNullContainer<T>
where T : notnull
{
}
#nullable restore
where
子句还可包括 unmanaged
约束。 unmanaged
约束将类型参数限制为名为"非托管类型"的类型。 unmanaged
约束使得在 C# 中编写低级别的互操作代码变得更容易。 此约束支持跨所有非托管类型的可重用例程。 unmanaged
约束不能与 class
或 struct
约束结合使用。 unmanaged
约束强制该类型必须为 struct
:
csharp
class UnManagedWrapper<T>
where T : unmanaged
{ }
where
子句也可能包括构造函数约束 new()
。 该约束使得能够使用 new
运算符创建类型参数的实例。 new() 约束可以让编译器知道:提供的任何类型参数都必须具有可访问的无参数构造函数。 例如:
csharp
public class MyGenericClass<T> where T : IComparable<T>, new()
{
// The following line is not possible without new() constraint:
T item = new T();
}
new()
约束出现在 where
子句的最后。 new()
约束不能与 struct
或 unmanaged
约束结合使用。 所有满足这些约束的类型必须具有可访问的无参数构造函数,这使得 new()
约束冗余。
对于多个类型参数,每个类型参数都使用一个 where
子句,例如:
csharp
public interface IMyInterface { }
namespace CodeExample
{
class Dictionary<TKey, TVal>
where TKey : IComparable<TKey>
where TVal : IMyInterface
{
public void Add(TKey key, TVal val) { }
}
}
还可将约束附加到泛型方法的类型参数,如以下示例所示:
csharp
public void MyMethod<T>(T t) where T : IMyInterface { }
请注意,对于委托和方法两者来说,描述类型参数约束的语法是一样的:
csharp
delegate T MyDelegate<T>() where T : new();
有关泛型委托的信息,请参阅泛型委托。
有关约束的语法和用法的详细信息,请参阅类型参数的约束。
空处理Nullables
值类型 VS 引用类型
- 在C#中,值对象不可为null
- 声明值对象,C#编译器会赋予默认的初始化数据
- 布尔默认false
- 整数默认为0
- 浮点数默认为0
可为 null 值类型 T?
表示其基础值类型T
的所有值及额外的 null 值。 例如,可以将以下三个值中的任意一个指定给 bool?
变量:true
、false
或 null
。 基础值类型 T
本身不能是可为空的值类型。
任何可为空的值类型都是泛型 System.Nullable 结构的实例。 可使用以下任何一种可互换形式引用具有基础类型 T
的可为空值类型:Nullable<T>
或 T?
。
需要表示基础值类型的未定义值时,通常使用可为空的值类型。 例如,布尔值或 bool
变量只能为 true
或 false
。 但是,在某些应用程序中,变量值可能未定义或缺失。 例如,某个数据库字段可能包含 true
或 false
,或者它可能不包含任何值,即 NULL
。 在这种情况下,可以使用 bool?
类型。
声明和赋值
由于值类型可隐式转换为相应的可为空的值类型,因此可以像向其基础值类型赋值一样,向可为空值类型的变量赋值。 还可分配 null
值。 例如:
csharp
double? pi = 3.14;
char? letter = 'a';
int m2 = 10;
int? m = m2;
bool? flag = null;
// An array of a nullable value type:
int?[] arr = new int?[10];
可为空值类型的默认值表示 null
,也就是说,它是其 Nullable.HasValue 属性返回 false
的实例。
检查可为空值类型的实例
可以将 is
运算符与类型模式结合使用,既检查 null
的可为空值类型的实例,又检索基础类型的值:
csharp
int? a = 42;
if (a is int valueOfA)
{
Console.WriteLine($"a is {valueOfA}");
}
else
{
Console.WriteLine("a does not have a value");
}
// Output:
// a is 42
始终可以使用以下只读属性来检查和获取可为空值类型变量的值:
- Nullable.HasValue 指示可为空值类型的实例是否有基础类型的值。
- 如果 HasValue 为
true
,则 Nullable.Value 获取基础类型的值。 如果 HasValue 为false
,则 Value 属性将引发 InvalidOperationException。
以下示例中的使用 HasValue
属性在显示值之前测试变量是否包含该值:
csharp
int? b = 10;
if (b.HasValue)
{
Console.WriteLine($"b is {b.Value}");
}
else
{
Console.WriteLine("b does not have a value");
}
// Output:
// b is 10
还可将可为空的值类型的变量与 null
进行比较,而不是使用 HasValue
属性,如以下示例所示:
csharp
int? c = 7;
if (c != null)
{
Console.WriteLine($"c is {c.Value}");
}
else
{
Console.WriteLine("c does not have a value");
}
// Output:
// c is 7
从可为空的值类型转换为基础类型
如果要将可为空值类型的值分配给不可以为 null 的值类型变量,则可能需要指定要分配的替代 null
的值。 使用 Null 合并操作符??
执行此操作(也可将 Nullable.GetValueOrDefault(T) 方法用于相同的目的):
csharp
int? a = 28;
int b = a ?? -1;
Console.WriteLine($"b is {b}"); // output: b is 28
int? c = null;
int d = c ?? -1;
Console.WriteLine($"d is {d}"); // output: d is -1
如果要使用基础值类型的默认值来替代 null
,请使用 Nullable.GetValueOrDefault() 方法。
还可以将可为空的值类型显式强制转换为不可为 null 的类型,如以下示例所示:
csharp
int? n = null;
//int m1 = n; // Doesn't compile
int n2 = (int)n; // Compiles, but throws an exception if n is null
在运行时,如果可为空的值类型的值为 null
,则显式强制转换将抛出 InvalidOperationException。
不可为 null 的值类型 T
隐式转换为相应的可为空值类型 T?
。
提升的运算符
预定义的一元运算符和二元运算符或值类型 T
支持的任何重载运算符也受相应的可为空值类型 T?
支持。 如果一个或全部两个操作数为 null
,则这些运算符(也称为提升的运算符)将生成 null
;否则,运算符使用其操作数所包含的值来计算结果。 例如:
csharp
int? a = 10;
int? b = null;
int? c = 10;
a++; // a is 11
a = a * c; // a is 110
a = a + b; // a is null
备注
对于
bool?
类型,预定义的&
和|
运算符不遵循此部分中描述的规则:即使其中一个操作数为null
,运算符计算结果也可以不为 NULL。 有关详细信息,请参阅布尔逻辑运算符一文的可以为 null 的布尔逻辑运算符部分。
对于比较运算符<
、>
、<=
和 >=
,如果一个或全部两个操作数都为 null
,则结果为 false
;否则,将比较操作数的包含值。 请勿作出如下假定:由于某个特定的比较(例如 <=
)返回 false
,则相反的比较 (>
) 返回 true
。 以下示例显示 10
- 既不大于等于
null
, - 也不小于
null
csharp
int? a = 10;
Console.WriteLine($"{a} >= null is {a >= null}");
Console.WriteLine($"{a} < null is {a < null}");
Console.WriteLine($"{a} == null is {a == null}");
// Output:
// 10 >= null is False
// 10 < null is False
// 10 == null is False
int? b = null;
int? c = null;
Console.WriteLine($"null >= null is {b >= c}");
Console.WriteLine($"null == null is {b == c}");
// Output:
// null >= null is False
// null == null is True
对于相等运算符==
,如果两个操作数都为 null
,则结果为 true
;如果只有一个操作数为 null
,则结果为 false
;否则,将比较操作数的包含值。
对于不等运算符!=
,如果两个操作数都为 null
,则结果为 false
;如果只有一个操作数为 null
,则结果为 true
;否则,将比较操作数的包含值。
如果在两个值类型之间存在用户定义的转换,则还可在相应的可为空值类型之间使用同一转换。
装箱和取消装箱
可为空值类型的实例 T?
已装箱,如下所示:
可将值类型 T
的已装箱值取消装箱到相应的可为空值类型 T?
,如以下示例所示:
csharp
int a = 41;
object aBoxed = a;
int? aNullable = (int?)aBoxed;
Console.WriteLine($"Value of aNullable: {aNullable}");
object aNullableBoxed = aNullable;
if (aNullableBoxed is int valueOfA)
{
Console.WriteLine($"aNullableBoxed is boxed int: {valueOfA}");
}
// Output:
// Value of aNullable: 41
// aNullableBoxed is boxed int: 41
如何确定可为空的值类型
下面的示例演示了如何确定 System.Type 实例是否表示已构造的可为空值类型,即,具有指定类型参数 T
的 System.Nullable 类型:
csharp
Console.WriteLine($"int? is {(IsNullable(typeof(int?)) ? "nullable" : "non nullable")} value type");
Console.WriteLine($"int is {(IsNullable(typeof(int)) ? "nullable" : "non-nullable")} value type");
bool IsNullable(Type type) => Nullable.GetUnderlyingType(type) != null;
// Output:
// int? is nullable value type
// int is non-nullable value type
如示例所示,使用 typeof 运算符来创建 System.Type 实例。
如果要确定实例是否是可为空的值类型,请不要使用 Object.GetType 方法获取要通过前面的代码测试的 Type 实例。 如果对值类型可为空的实例调用 Object.GetType 方法,该实例将装箱到 Object。 由于对可为空的值类型的非 NULL 实例的装箱等同于对基础类型的值的装箱,因此 GetType 会返回表示可为空的值类型的基础类型的 Type 实例:
csharp
int? a = 17;
Type typeOfA = a.GetType();
Console.WriteLine(typeOfA.FullName);
// Output:
// System.Int32
另外,请勿使用 is 运算符来确定实例是否是可为空的值类型。 如以下示例所示,无法使用 is
运算符区分可为空值类型实例的类型与其基础类型实例:
csharp
int? a = 14;
if (a is int)
{
Console.WriteLine("int? instance is compatible with int");
}
int b = 17;
if (b is int?)
{
Console.WriteLine("int instance is compatible with int?");
}
// Output:
// int? instance is compatible with int
// int instance is compatible with int?
请改为使用第一个示例中的 Nullable.GetUnderlyingType 和 typeof 运算符,以检查实例是否具有可为空的值类型。
备注
此部分中所述的方法不适用于可为空的引用类型的情况。
扩展方法
扩展方法使你能够向现有类型"添加"方法,而无需创建新的派生类型、重新编译或以其他方式修改原始类型。 扩展方法是一种静态方法,但可以像扩展类型上的实例方法一样进行调用。 对于用 C#、F# 和 Visual Basic 编写的客户端代码,调用扩展方法与调用在类型中定义的方法没有明显区别。
最常见的扩展方法是 LINQ 标准查询运算符,它将查询功能添加到现有的 System.Collections.IEnumerable 和 System.Collections.Generic.IEnumerable 类型。 若要使用标准查询运算符,请先使用 using System.Linq
指令将它们置于范围中。 然后,任何实现了 IEnumerable 的类型看起来都具有 GroupBy、OrderBy、Average 等实例方法。 在 IEnumerable 类型的实例(如 List 或 Array)后键入"dot"时,可以在 IntelliSense 语句完成中看到这些附加方法。
OrderBy 示例
下面的示例演示如何对一个整数数组调用标准查询运算符 OrderBy
方法。 括号里面的表达式是一个 lambda 表达式。 很多标准查询运算符采用 Lambda 表达式作为参数,但这不是扩展方法的必要条件。 有关详细信息,请参阅 Lambda 表达式。
csharp
class ExtensionMethods2
{
static void Main()
{
int[] ints = [10, 45, 15, 39, 21, 26];
var result = ints.OrderBy(g => g);
foreach (var i in result)
{
System.Console.Write(i + " ");
}
}
}
//Output: 10 15 21 26 39 45
扩展方法被定义为静态方法,但它们是通过实例方法语法进行调用的。 它们的第一个参数指定方法操作的类型。 参数前面是此修饰符。 仅当你使用 using
指令将命名空间显式导入到源代码中之后,扩展方法才位于范围中。
下面的示例演示为 System.String 类定义的一个扩展方法。 它是在非嵌套的、非泛型静态类内部定义的:
csharp
namespace ExtensionMethods
{
public static class MyExtensions
{
public static int WordCount(this string str)
{
return str.Split(new char[] { ' ', '.', '?' },
StringSplitOptions.RemoveEmptyEntries).Length;
}
}
}
可使用此 WordCount
指令将 using
扩展方法置于范围中:
csharp
using ExtensionMethods;
而且,可以使用以下语法从应用程序中调用该扩展方法:
csharp
string s = "Hello Extension Methods";
int i = s.WordCount();
在代码中,可以使用实例方法语法调用该扩展方法。 编译器生成的中间语言 (IL) 会将代码转换为对静态方法的调用。 并未真正违反封装原则。 扩展方法无法访问它们所扩展的类型中的专用变量。
MyExtensions
类和 WordCount
方法都是 static
,可以像所有其他 static
成员那样对其进行访问。 WordCount
方法可以像其他 static
方法一样调用,如下所示:
csharp
string s = "Hello Extension Methods";
int i = MyExtensions.WordCount(s);
上述 C# 代码:
- 声明并分配一个名为
s
和值为"Hello Extension Methods"
的新string
。 - 调用
MyExtensions.WordCount
给定自变量s
有关详细信息,请参阅如何实现和调用自定义扩展方法。
通常,你更多时候是调用扩展方法而不是实现你自己的扩展方法。 由于扩展方法是使用实例方法语法调用的,因此不需要任何特殊知识即可从客户端代码中使用它们。 若要为特定类型启用扩展方法,只需为在其中定义这些方法的命名空间添加 using
指令。 例如,若要使用标准查询运算符,请将此 using
指令添加到代码中:
csharp
using System.Linq;
(你可能还必须添加对 System.Core.dll 的引用。)你将注意到,标准查询运算符现在作为可供大多数 IEnumerable 类型使用的附加方法显示在 IntelliSense 中。
在编译时绑定扩展方法
可以使用扩展方法来扩展类或接口,但不能重写扩展方法。 与接口或类方法具有相同名称和签名的扩展方法永远不会被调用。 编译时,扩展方法的优先级总是比类型本身中定义的实例方法低。 换句话说,如果某个类型具有一个名为 Process(int i)
的方法,而你有一个具有相同签名的扩展方法,则编译器总是绑定到该实例方法。 当编译器遇到方法调用时,它首先在该类型的实例方法中寻找匹配的方法。 如果未找到任何匹配方法,编译器将搜索为该类型定义的任何扩展方法,并且绑定到它找到的第一个扩展方法。
示例
下面的示例演示 C# 编译器在确定是将方法调用绑定到类型上的实例方法还是绑定到扩展方法时所遵循的规则。 静态类 Extensions
包含为任何实现了 IMyInterface
的类型定义的扩展方法。 类 A
、B
和 C
都实现了该接口。
MethodB
扩展方法永远不会被调用,因为它的名称和签名与这些类已经实现的方法完全匹配。
如果编译器找不到具有匹配签名的实例方法,它会绑定到匹配的扩展方法(如果存在这样的方法)。
csharp
// Define an interface named IMyInterface.
namespace DefineIMyInterface
{
public interface IMyInterface
{
// Any class that implements IMyInterface must define a method
// that matches the following signature.
void MethodB();
}
}
// Define extension methods for IMyInterface.
namespace Extensions
{
using System;
using DefineIMyInterface;
// The following extension methods can be accessed by instances of any
// class that implements IMyInterface.
public static class Extension
{
public static void MethodA(this IMyInterface myInterface, int i)
{
Console.WriteLine
("Extension.MethodA(this IMyInterface myInterface, int i)");
}
public static void MethodA(this IMyInterface myInterface, string s)
{
Console.WriteLine
("Extension.MethodA(this IMyInterface myInterface, string s)");
}
// This method is never called in ExtensionMethodsDemo1, because each
// of the three classes A, B, and C implements a method named MethodB
// that has a matching signature.
public static void MethodB(this IMyInterface myInterface)
{
Console.WriteLine
("Extension.MethodB(this IMyInterface myInterface)");
}
}
}
// Define three classes that implement IMyInterface, and then use them to test
// the extension methods.
namespace ExtensionMethodsDemo1
{
using System;
using Extensions;
using DefineIMyInterface;
class A : IMyInterface
{
public void MethodB() { Console.WriteLine("A.MethodB()"); }
}
class B : IMyInterface
{
public void MethodB() { Console.WriteLine("B.MethodB()"); }
public void MethodA(int i) { Console.WriteLine("B.MethodA(int i)"); }
}
class C : IMyInterface
{
public void MethodB() { Console.WriteLine("C.MethodB()"); }
public void MethodA(object obj)
{
Console.WriteLine("C.MethodA(object obj)");
}
}
class ExtMethodDemo
{
static void Main(string[] args)
{
// Declare an instance of class A, class B, and class C.
A a = new A();
B b = new B();
C c = new C();
// For a, b, and c, call the following methods:
// -- MethodA with an int argument
// -- MethodA with a string argument
// -- MethodB with no argument.
// A contains no MethodA, so each call to MethodA resolves to
// the extension method that has a matching signature.
a.MethodA(1); // Extension.MethodA(IMyInterface, int)
a.MethodA("hello"); // Extension.MethodA(IMyInterface, string)
// A has a method that matches the signature of the following call
// to MethodB.
a.MethodB(); // A.MethodB()
// B has methods that match the signatures of the following
// method calls.
b.MethodA(1); // B.MethodA(int)
b.MethodB(); // B.MethodB()
// B has no matching method for the following call, but
// class Extension does.
b.MethodA("hello"); // Extension.MethodA(IMyInterface, string)
// C contains an instance method that matches each of the following
// method calls.
c.MethodA(1); // C.MethodA(object)
c.MethodA("hello"); // C.MethodA(object)
c.MethodB(); // C.MethodB()
}
}
}
/* Output:
Extension.MethodA(this IMyInterface myInterface, int i)
Extension.MethodA(this IMyInterface myInterface, string s)
A.MethodB()
B.MethodA(int i)
B.MethodB()
Extension.MethodA(this IMyInterface myInterface, string s)
C.MethodA(object obj)
C.MethodA(object obj)
C.MethodB()
*/
常见使用模式
集合功能
过去,创建"集合类"通常是为了使给定类型实现 System.Collections.Generic.IEnumerable 接口,并实现对该类型集合的功能。 创建这种类型的集合对象没有任何问题,但也可以通过对 System.Collections.Generic.IEnumerable 使用扩展来实现相同的功能。 扩展的优势是允许从任何集合(如 System.Array 或实现该类型 System.Collections.Generic.IEnumerable 的 System.Collections.Generic.List)调用功能。 可以在本文前面的内容中找到使用 Int32 的数组的示例。
特定于层的功能
使用洋葱架构或其他分层应用程序设计时,通常具有一组域实体或数据传输对象,可用于跨应用程序边界进行通信。 这些对象通常不包含任何功能,或者只包含适用于应用程序的所有层的最少功能。 使用扩展方法可以添加特定于每个应用程序层的功能,而无需使用其他层中不需要的方法来向下加载对象。
csharp
public class DomainEntity
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
static class DomainEntityExtensions
{
static string FullName(this DomainEntity value)
=> $"{value.FirstName} {value.LastName}";
}
扩展预定义类型
当需要创建可重用功能时,我们无需创建新对象,而是可以扩展现有类型,例如 .NET 或 CLR 类型。 例如,如果不使用扩展方法,我们可能会创建 Engine
或 Query
类,对可从代码中的多个位置调用的 SQL Server 执行查询。 但是,如果换做使用扩展方法扩展 System.Data.SqlClient.SqlConnection 类,就可以从与 SQL Server 连接的任何位置执行该查询。 其他示例可能是向 System.String 类添加常见功能、扩展 System.IO.Stream 和 System.Exception 对象的数据处理功能以实现特定的错误处理功能。 这些用例的类型仅受想象力和判断力的限制。
使用 struct
类型扩展预定义类型可能很困难,因为它们已通过值传递给方法。 这意味着将对结构的副本进行任何结构更改。 扩展方法退出后,将不显示这些更改。 可以将 ref
修饰符添加到第一个参数,使其成为 ref
扩展方法。 ref
关键字可以在 this
关键字之前或之后显示,不会有任何语义差异。 添加 ref
修饰符表示第一个参数是按引用传递的。 在这种情况下,可以编写扩展方法来更改要扩展的结构的状态(请注意,私有成员不可访问)。 仅允许值类型或受结构约束的泛型类型(有关详细信息,请参阅 struct
约束)作为 ref
扩展方法的第一个参数。 以下示例演示如何使用 ref
扩展方法直接修改内置类型,而无需重新分配结果或使用 ref
关键字传递函数:
csharp
public static class IntExtensions
{
public static void Increment(this int number)
=> number++;
// Take note of the extra ref keyword here
public static void RefIncrement(this ref int number)
=> number++;
}
public static class IntProgram
{
public static void Test()
{
int x = 1;
// Takes x by value leading to the extension method
// Increment modifying its own copy, leaving x unchanged
x.Increment();
Console.WriteLine($"x is now {x}"); // x is now 1
// Takes x by reference leading to the extension method
// RefIncrement changing the value of x directly
x.RefIncrement();
Console.WriteLine($"x is now {x}"); // x is now 2
}
}
下一个示例演示用户定义的结构类型的 ref
扩展方法:
csharp
public struct Account
{
public uint id;
public float balance;
private int secret;
}
public static class AccountExtensions
{
// ref keyword can also appear before the this keyword
public static void Deposit(ref this Account account, float amount)
{
account.balance += amount;
// The following line results in an error as an extension
// method is not allowed to access private members
// account.secret = 1; // CS0122
}
}
public static class AccountProgram
{
public static void Test()
{
Account account = new()
{
id = 1,
balance = 100f
};
Console.WriteLine($"I have ${account.balance}"); // I have $100
account.Deposit(50f);
Console.WriteLine($"I have ${account.balance}"); // I have $150
}
}
通用准则
尽管通过修改对象的代码来添加功能,或者在合理和可行的情况下派生新类型等方式仍是可取的,但扩展方法已成为在整个 .NET 生态系统中创建可重用功能的关键选项。 对于原始源不