A Tour Of the C# Language

原文地址:https://docs.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/{: target=”_blank” }

A Tour Of C# Language

C#(读作See Sharp)是一种简单、现代化、面向对象、类型安全的C家族计算机语言。

C#支持面向对象(OO),同时,它还支持面向组件(CO, Component Oriented),当代软件开发越来越依赖于自包含、自描述形式的软件组件。组件的关键是属性、方法、事件编程模型,组件的一些描述性属性构成了组件的说明文档。

垃圾回收、异常处理、类型安全设计等特性使开发者更容易构建可靠、持久稳健的应用程序。垃圾回收机制自动回收不可用对象占用的内存;异常处理提供一个结构化、可扩展的错误检测和恢复的方法;类型安全使引用未初始化的变量、数组过界访问、执行未检查的类型转换不再发生。

包括基本数据类型(例如:int, double)在内的数据类型都继承自唯一的根类型object,这样做的好处是所有的类型共享一组基本操作,任何类型的值按照一致的方式被保存、传递和使用。

目录

关键字列表

1 Hello World

C#版本的Hello World程序如下:

1
2
3
4
5
6
7
8
using System;
class Hello
{
static void Main()
{
Console.WriteLine("Hello World");
}
}

通常,一个C#源码文件的扩展名是.cs,假定Hello World程序的文件名是hello.cs,编译这个程序的命令是:

1
csc hello.cs

1.1 名字空间

程序第一行using System加载名字空间System,名字空间是C#用来层次化组织程序和库的方式。一个名字空间可以包括若干类型定义和其他名字空间,举例来看,Console是在名字空间System中定义的类,在名字空间System中定义的其他名字空间有IO、Collections等。using命令使用户非限定地使用指定名字空间里的名字,在上例中,语句Console.WriteLine(“Hello World”)就是语句System.Console.WriteLine(“Hello World”)的非限定版本。

1.2 static限定符

Hello类只有一个成员方法Main,Main被限定为static,对象实例的普通方法使用this关键字来访问特定对象,但是一个static方法不需要this关键字来访问实例对象中的任何成员。按照约定,以static关键字修饰并命名为Main的方法作为一个程序的入口点来使用。

1.3 方法

Hello程序的输出是System名字空间中的Console类的WriteLine方法产生的,这个方法由标准类库提供,默认情况下,C#编译器会自动引用这个库。

1.4 C#语言的组成要素

  • 程序结构

有关C#程序结构的概念有程序(Programs),名字空间,类型,成员,和原件。

  • 类型和变量

值类型、引用类型、变量

  • 表达式

表达式由操作符和操作数构成,表达式产生一个值

  • 语句

你可以使用语句来描述程序动作

  • 类和对象

类也叫类型,一个类包含若干成员,对象是类具体的实例

  • 结构

结构是描述数据的值类型

  • 数组

一个数组存储一定数量的变量,数组内的变量可以通过索引(下标)来访问

  • 接口

接口定义可以应用到类和结构的抽象描述,接口的成员可以是方法、属性、事件、索引。接口本身并不实现它定义的东西,但应用该接口的类和结构必须实现它们。

  • 枚举类型

枚举类型是一种数值类型,它的取值范围是一个确定的命名常量集合。

  • Delegates

类似函数指针,C#赋予它面向对象和类型安全特性。

  • 属性(Attribute)

属性为程序的类型、成员和其他元素附加描述信息。

2 程序结构

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
using System;
namespace Acme.Collections
{
public class Stack
{
Entry top;
public void Push(object data)
{
top = new Entry(top, data);
}

public object Pop()
{
if (top == null)
{
throw new InvalidOperationException();
}
object result = top.data;
top = top.next;
return result;
}

class Entry
{
public Entry next;
public object data;
public Entry(Entry next, object data)
{
this.next = next;
this.data = data;
}
}
}
}
1
csc /t:library acme.cs

原件里保存了编译后的中间代码(IL)和元数据,一个原件在执行之前,C#运行时的JIT(Just In Time)编译器会将中间代码编译成处理器特有的机器码。因为一个原件已经存储了程序的元数据,C#程序不需要include指令和头文件。

1
csc /r:acme.dll example.cs

C#允许你把源代码分散在许多文件中,在编译时,所有文件就像写在同一个大文件里,前向声明是不需要的。

源代码文件名不需要和定义在文件中的public class类名相同,你可以在一个源文件里定义多个public class。

3 类型和变量

C#有值类型和引用类型这两种类型种类,值类型的变量存储自身数据,引用类型的变量仅存储到自身数据的引用,后者也被称为对象类型。

值类型又进一步划分为简单类型、枚举类型、可为空类型。引用类型又进一步划分为类、接口类型、数组类型、和Delegate类型。

C#的类型系统总览:

  • 值类型
    • 简单类型
      • 带符号整数: sbyte、short、int、long
      • 不带符号整数:byte、ushort、uint、ulong
      • Unicode字符:char
      • IEEE浮点数:float double
      • 高精度浮点数:decimal
      • 布尔类型:bool
    • 枚举类型
      • 用户自定义枚举类型:emum E { … }
    • 结构
      • 用户自定义结构:struct S { … }
    • 可为空类型
      • 可为空类型可以扩展其他类型,使得它们可以表示一个额外的值null
  • 引用类型
      • 所有类型的的根类型:object
      • Unicode字符串:string
      • 用户自定义类:class C { … }
    • 接口
      • 用户自定义接口:interface I { … }
    • 数组
      • 一维或多维数组,例如:int[], int[ , ]
    • Delegate
      • 用户自定义Delegate:delegate int D(…)

注:

  • 以上八种整数类型可表示8位、16位、32位、64位有符号和无符号整数
  • float和double分别表示32位和64位IEC-60559浮点类型
  • decimal是一个128位的浮点类型,可用于财务和金融计算,至少保证28位小数精度
  • bool类型只可以取true和false这两个值
  • C#字符和字符串采用UTF-16编码格式,char类型表示一个UTF-16单位,string类型表示一个UTF-16序列
  • 用户可创建五种自定义类型,分别是:类、接口、接口、枚举、delegate。
  • 一个类可以有数据成员和函数成员(方法、属性、和其他),支持继承和多态,结构与类相似,也可以有数据成员和函数成员,不同的是,结构是值类型,不支持继承,所有结构隐式地直接继承自object。
  • 接口是一种成员为方法说明的抽象命名集合,接口可以继承自多个结构,一个类或者结构可以实现多个接口。
  • 每个枚举类型都有一个对应八个整数类型的一种潜在类型。
  • C#支持一维和多维数组,int[]声明一个一维int数组,int[ , ]声明一个二维int数组,int[][]声明一个一维int[]数组。
  • 可为空类型也不需要在使用前定义,对于每个不可为空类型T来说,都有一个可为空T?类型与之对应,例如int?,int?类型的变量除了可以表示int类型的所有值以外,还可以表示额外的值null。
  • C#的对象直接或间接的继承自object,所有引用类型的对象都可以当作object的对象来使用,如果想把一个值类型当成object来使用,可以借助包装和拆包操作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    using System;
    class BoxingExample
    {
    static void Main()
    {
    int i = 123;
    object o = i; // Boxing
    int j = (int)o; // Unboxing
    }
    }

-

4 表达式

表达式由操作符和操作数构成,操作符规定一个表达式对操作数进行什么运算,操作符的例子有:+, -, *, /, new,操作数的例子有:字面量、域、局部变量、子表达式。

C#表达式的运算符优先级和结合性的概念与其他语言相同,不再赘述。关于结合性,有总结如下:

  • 除了赋值运算符,其他所有二元运算符都是左结合的
  • 赋值运算符(包括三元赋值运算符)是右结合的

大多数的操作符可以被重载,重载机制让基本运算符可以运用在一个操作数为自定义类型或两个操作数都为自定义类型的表达式中。

下表列出了C#语言的所有运算符,同一个类别中的运算符拥有一致的优先级,类别优先级从上往下依次降低

  • 基本运算符
    • x.m 成员访问
    • x(…) 方法或delegate调用
    • x[…] 数组成员访问
    • x++ 后自增
    • x— 后自减
    • new T(…) 对象或delegate创建
    • new T(…){…} 带初始化的创建
    • new {…} 创建匿名对象
    • new T[…] 创建数组
    • typeof(T) 获取T类型的type对象
    • checked(x) 没看懂原文
    • unchecked(x) 没看懂原文
    • default(T) 获取T类型的默认值
    • delegate {…} 匿名函数(方法)
  • 一元运算符
    • +x 没看懂原文
    • -x 取负
    • !x 逻辑取反
    • ~x 按位区分
    • ++x 前自增
    • —x 后自增
    • (T)x 显式类型转换
    • await x 异步等待x结束
  • 乘除
    • x * y 乘法运算
    • x / y 除法运算
    • x % y 取模运算
  • 加减
    • x + y 算术加、字符串连接、delegate组合
    • x - y 算术减、delegate移除
  • 移位
    • x << y 左移
    • x >> y 右移
  • 关系运算和类型判断
    • x < y 小于
    • x > y 大于
    • x <= y 小于或等于
    • x >= y 大于或等于
    • x is T 判断x是否是T,表达式产生bool值
    • x as T 如果x是T,返回(T) x,否则返回null
  • 相等
    • x == y 相等
    • x != y 不相等
  • 逻辑与
    • x & y 数值按位与、bool逻辑与
  • 异或
    • x ^ y 数值按位异或、bool逻辑异或
  • 逻辑或
    • x | y 数值按位或、bool逻辑或
  • 条件与
    • x && y 如果x,y同时成立,返回true
  • 条件或
    • x || y 如果x,y有一个成立,返回true
  • Null coalescing
    • x ?? y 如果x==null,返回y,否则返回x
  • 三元运算
    • x ? y : z 如果x为真,返回y,否则返回z
  • 赋值和匿名函数
    • x = y 赋值
    • x op= y 复合赋值。op=的形式可以是
      • *= /= %= += -= <<= >>= &= ^= |=
    • (T x) => y 匿名函数(lambda表达式)

5 语句

用语句来定义一个程序的行为,C#支持多种类型语句,其中的一些定义为嵌入式语句。

  • {}称作一个块,你可以在块中书写一条或多条语句

  • 变量定义语句用于定义局部变量和常量

  • 表达式语句用于对表达式求值,可以被视作是语句的表达式有方法调用、创建对象、赋值、自增自减运算和await表达式。

  • 选择语句计算一个特定表达式的值,并根据求值的结果选择一条或多条语句接着执行。选择语句有if和switch语句。

  • 迭代语句用于循环执行一个代码块,迭代语句有while,do,for,foreach语句。

  • 跳转语句用于转交控制流程,跳转语句有break,continue,goto,throw,return,和yield。

  • try…catch语句用于捕获一个代码块产生的异常,try…finally语句为一个try…catch块指定结束代码,无论异常是否发生,它们都将执行。

  • checked和unchecked语句用于控制整数数值计算和类型转换的溢出检查上下文(没看懂原文)

  • lock语句首先获取应用在指定对象上的互斥锁,执行一条语句,然后释放互斥锁。

  • using语句首先获取一个资源,执行一条语句,然后释放资源。

我们用一个个具体例子来说明每种语句的语法。其中,表达式语句、if语句、switch语句、while语句、do语句、for语句、break语句、continue语句、goto语句、return语句的用法与其他C家族语言并无不同。我们省略这些语句的示例。

5.1 定义局部变量

1
2
3
4
5
6
7
static void Declarations(string[] args)
{
int a;
int b = 2, c = 3;
a = 1;
Console.WriteLine(a + b + c);
}

5.2 定义局部常量

1
2
3
4
5
6
static void ConstantDeclarations(string[] args)
{
const float pi = 3.1415927f;
const int r = 25;
Console.WriteLine(pi * r * r);
}

5.3 foreach语句

1
2
3
4
5
6
7
static void ForEachStatement(string[] args)
{
foreach (string s in args)
{
Console.WriteLine(s);
}
}

5.4 yield语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static System.Collections.Generic.IEnumerable<int> Range(int from, int to)
{
for (int i = from; i < to; i++)
{
yield return i;
}
yield break;
}
static void YieldStatement(string[] args)
{
foreach (int i in Range(-10,10))
{
Console.WriteLine(i);
}
}

5.5 throw和try语句

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
static double Divide(double x, double y)
{
if (y == 0)
throw new DivideByZeroException();
return x / y;
}
static void TryCatch(string[] args)
{
try
{
if (args.Length != 2)
{
throw new InvalidOperationException("Two numbers required");
}
double x = double.Parse(args[0]);
double y = double.Parse(args[1]);
Console.WriteLine(Divide(x, y));
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
finally
{
Console.WriteLine("Good bye!");
}
}

5.6 checked和unchecked语句

1
2
3
4
5
6
7
8
9
10
11
12
static void CheckedUnchecked(string[] args)
{
int x = int.MaxValue;
unchecked
{
Console.WriteLine(x + 1); // Overflow
}
checked
{
Console.WriteLine(x + 1); // Exception
}
}

5.7 lock语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Account
{
decimal balance;
private readonly object sync = new object();
public void Withdraw(decimal amount)
{
lock (sync)
{
if (amount > balance)
{
throw new Exception(
"Insufficient funds");
}
balance -= amount;
}
}
}

5.8 using语句

1
2
3
4
5
6
7
8
9
static void UsingStatement(string[] args)
{
using (TextWriter w = File.CreateText("test.txt"))
{
w.WriteLine("Line one");
w.WriteLine("Line two");
w.WriteLine("Line three");
}
}

6 类和对象

类是C#类型系统的基础,它封装了类的属性和类的方法,类为动态创建的示例(也称为对象)提供定义,继承和多态性使子类能够继承和多样化父类的属性和方法。

一个完整的类定义由类定义头和类定义体组成,类定义头为类指定属性、描述符、类名、基类、要实现的接口。类定义体包含类具体的成员定义。

一个名为Point的简单类定义如下:

1
2
3
4
5
6
7
8
9
public class Point
{
public int x, y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}

使用new操作符来实例化一个类,new操作符为类的实例分配内存空间,调用类的构造函数初始化类,并返回类的引用。

下例创建两个Point类的实例并把它们的引用赋值给变量p1和p2

1
2
Point p1 = new Point(0, 0);
Point p2 = new Point(10, 20);

当一个类的实例不再使用,C#运行时会自动回收它占用的内存空间,用户不需要手动释放一个类实例占用的内存空间(C#也没提供这样的操作)。

6.1 成员

成员按作用范围可划分为静态成员和实例成员,静态成员属于类本身,实例成员属于每一个具体的实例。

按类型分,一个类中的成员有如下几种:

  • 常量
    • 定义作用于本类的常量
  • 变量
    • 定义作用于本类的变量
  • 方法
    • 定义本类可以进行的运算和行为
  • 属性
    • 读取或写入命名属性(没看懂原文)
  • 索引
    • 像数组一样索引类的所有实例(没看懂原文)
  • 事件
    • 可以从类产生的通知
  • 运算符
    • 类可以进行的类型转换以及表达式运算
  • 构造函数
    • 用于初始化类的实例或类本身的方法
  • 解构函数
    • 在实例销毁之前调用的一个方法
  • 子类型
    • 定义本类的子类型

6.2 可见性

有六种可见性修饰符可以应用于类的成员,以控制这些成员在代码的不同区域的可见性。

  • public
    • 存取不受区域限制
  • protected
    • 只有所属类和所属类的子类可以访问这些成员
  • internal
    • 存取限制在所属原件(.exe, .dll, etc.)
  • protected internal
    • protected和internal的组合
  • private
    • 只有成员的所属类可以访问该成员
  • private protected
    • 只有成员的所属类和在所属原件中定义的所属类的子类可以访问

6.3 类型参数

类定义可以要求用户在实例化类的对象时传入一组类型参数,相应的,使用了参数化类型的成员在实例中的类型就变成了实际指定的类型。这样的类有另一个名字 - 泛型类。除此之外,结构、接口、delegate都可以泛化。指定了实际参数的泛型类型叫做构造类型。

带类型参数的类定义:

1
2
3
4
5
public class Pair<TFirst,TSecond>
{
public TFirst First;
public TSecond Second;
}

创建带类型参数的类的实例:

1
2
3
Pair<int,string> pair = new Pair<int,string> { First = 1, Second = "two" };
int i = pair.First; // TFirst is int
string s = pair.Second; // TSecond is string

6.4 基类

可以为新的类定义指定基类,如果不指定,C#认为这个类的基类是object,下例中:Point3D的基类是Point,Point的基类是object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Point
{
public int x, y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
public class Point3D: Point
{
public int z;
public Point3D(int x, int y, int z) :
base(x, y)
{
this.z = z;
}
}

基类的子类隐式地继承除基类的实例、static构造函数、解构函数之外的所有成员。子类可以在此基础上添加新的成员,但不能移除任何一个从基类继承过来的成员。

基类和子类的引用都可以不经过显式转换地赋值给一个基类变量。

1
2
Point a = new Point(10, 20);
Point b = new Point3D(10, 20, 30);

6.5 域

域(成员变量)是所属一个类或实例的变量,使用static修饰符修饰的域属于类,没有使用static修饰符修饰的域属于实例,不论一个类有多少实例,static域只存储一份,而每个类的实例都对应一组自己的在类中定义的非static域。

下例中,Color类的每个实例都有一组自己的r,b,b域。然而,它们共享一组Black,White,Red,Green,和Blue域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Color
{
public static readonly Color Black = new Color(0, 0, 0);
public static readonly Color White = new Color(255, 255, 255);
public static readonly Color Red = new Color(255, 0, 0);
public static readonly Color Green = new Color(0, 255, 0);
public static readonly Color Blue = new Color(0, 0, 255);
private byte r, g, b;
public Color(byte r, byte g, byte b)
{
this.r = r;
this.g = g;
this.b = b;
}
}

使用readonly修饰符修饰的域具有只读属性,你只能在定义该域的时候或在类的构造函数中初始化它。

6.6 方法

方法定义了一个类或类的实例可以进行的运算或动作,static方法要借助类作为载体来调用,而非static方法则必须借助一个具体实例作为载体来调用。

你可以为一个方法指定一个参数列表,参数的类型可以是值类型或引用类型。方法一般会把参数列表中的参数作为输入执行特定的动作或者计算出一个结果,如果一个方法最终会产生一个结果,你应该同时为这个方法指定返回值类型。否则,返回值类型应该是void,表示这个方法不返回一个值。

方法也可以泛化,不过,大多数情况下,一个方法的类型参数可以通过方法的实际参数推断出来,因此不需要显式指定。

一个类中的所有方法的签名必须是两两不同的,影响方法签名的要素有方法名、类型参数个数、数量(没看懂原文)、修饰符、参数的类型。(返回值类型不在其中)

6.6.1 参数

参数用于把一个值或引用传递给方法,一个方法被调用,它就获得了相应的实际参数。一共有四种参数类型:值参数、引用参数、输出参数、参数数组。

值参数主要用于输入,它可以被看作所属方法的一个通过拷贝实际参数的值进行初始化的局部变量,对值参数的修改不会影响实际参数。

你可以为值参数指定一个默认值,调用者可以省去为一个已有默认值的参数指定实际参数的过程。该参数直接取得它自己的默认值。

引用参数向方法传递一个变量的引用。引用参数的实际参数必须是一个具有确定的值的变量,在方法体内操作一个引用参数,实际上操作的是调用者指定的实际参数,使用ref修饰符来声明一个引用参数。

下例演示了引用参数的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
class RefExample
{
static void Swap(ref int x, ref int y)
{
int temp = x;
x = y;
y = temp;
}
public static void SwapExample()
{
int i = 1, j = 2;
Swap(ref i, ref j);
Console.WriteLine($"{i} {j}"); // Outputs "2 1"
}
}

使用out修饰符来声明一个输出参数,输出参数也向方法传入一个变量的引用,在形式上跟引用参数一样,不同的是,实参不需要有确定的值,方法通常不依赖输出参数既有的值,而仅仅将输出赋值到输出参数中。(验证)

下例演示了输出参数的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System;
class OutExample
{
static void Divide(int x, int y, out int result, out int remainder)
{
result = x / y;
remainder = x % y;
}
public static void OutUsage()
{
Divide(10, 3, out int res, out int rem);
Console.WriteLine("{0} {1}", res, rem); // Outputs "3 1"
}
}
}

使用params修饰符来声明一个参数数组,只有参数列表的最后一个参数可以声明为参数数组,有了参数数组,你就可以向方法传递在数量上多于方法参数列表中的参数的实际参数,多余的参数会按照实际参数的顺序依次存放在参数数组中。你只能把params修饰符应用在一维数组上。

下例演示声明一个参数数组:

1
2
3
4
5
6
public class Console
{
public static void Write(string fmt, params object[] args) { }
public static void WriteLine(string fmt, params object[] args) { }
// ...
}

对于方法体来说,参数数组就像一个普通的数组参数一样,为参数数组指定实际参数有两种方式,一是指定一个跟参数数组类型一致的数组,二是指定一组类型符合参数数组元素类型的值,它们会自动构成情形一中的数组作为实际参数传递。所以,下面两个例子实际上是等价的。

例子一

1
Console.WriteLine("x={0} y={1} z={2}", x, y, z);

例子二

1
2
3
4
5
6
string s = "x={0} y={1} z={2}";
object[] args = new object[3];
args[0] = x;
args[1] = y;
args[2] = z;
Console.WriteLine(s, args);

6.6.2 方法体和局部变量

方法体存放方法被调用时执行的语句

你可以在方法体内定义作用于单次方法调用的局部变量,C#要求一个局部变量在被访问之前已经获得一个确定的值了,如果在一个变量在访问之前没有任何初始化或赋值操作,编译器会报告错误。

方法体内的return语句为方法指定一个返回值,如果一个方法的返回值类型是void的,return语句仅结束当前方法,如果一个方法的返回值类型不是void的,你必须在return后面跟上一个表达式,它的值就作为函数的返回值来使用。

6.6.3 类方法和实例方法

使用static修饰符修饰的方法称作类方法(静态方法),类方法是属于类的,因此它不能访问类的非static成员,因为非static成员属于某个具体实例。

非static方法称作实例方法,实例方法既可以访问static成员,也可以访问调用实例的非static成员。this关键字用来引用一个实例方法的调用实例。

6.6.4 虚方法、覆写、和抽象方法

一个被virtual修饰符修饰的方法称为虚方法,反之,没有被virtual修饰符修饰的方法称作实方法

调用一个虚方法,运行时真正的类型将决定调用该方法的哪个实现,调用一个实方法,编译时的类型将决定调用该方法的哪个实现。

在子类中用override修饰符修饰父类方法就可以重写它,父类中的virtual方法为整个类层次结构引入一个基方法,而重写使每个子类有了一份对于该方法的特定实现。

abstract修饰符是对virtual的进一步延伸,定义了abstract方法的类本身也必须是abstract的,这个类也不能提供该方法的一个实现,由于类中有没有实现的abstract方法,你不能实例化这个类,继承一个abstract类的子类也必须使用abstract修饰,除非它为父类中的abstract方法提供了实现,你才可以像普通类那样实例化和使用它。

下例定义了一个代表表达式节点的abstract类Expression,以及三个分别对常量、变量、和四则运算求值的子类Constant,VariableReference和Operator。

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
using System;
using System.Collections.Generic;
public abstract class Expression
{
public abstract double Evaluate(Dictionary<string,object> vars);
}
public class Constant: Expression
{
double value;
public Constant(double value)
{
this.value = value;
}
public override double Evaluate(Dictionary<string,object> vars)
{
return value;
}
}
public class VariableReference: Expression
{
string name;
public VariableReference(string name)
{
this.name = name;
}
public override double Evaluate(Dictionary<string,object> vars)
{
object value = vars[name];
if (value == null)
{
throw new Exception("Unknown variable: " + name);
}
return Convert.ToDouble(value);
}
}
public class Operation: Expression
{
Expression left;
char op;
Expression right;
public Operation(Expression left, char op, Expression right)
{
this.left = left;
this.op = op;
this.right = right;
}
public override double Evaluate(Dictionary<string,object> vars)
{
double x = left.Evaluate(vars);
double y = right.Evaluate(vars);
switch (op) {
case '+': return x + y;
case '-': return x - y;
case '*': return x * y;
case '/': return x / y;
}
throw new Exception("Unknown operator");
}
}

这四个类可以用来对算术表达式进行建模,例如表达式x + 3可以用这个模型表示为

1
2
3
4
Expression e = new Operation(
new VariableReference("x"),
'+',
new Constant(3));

6.6.5 方法重载

重载机制让类中可以有签名不同但名字相同的方法。重载机制会选择跟实际调用的参数列表在类型上最匹配的重载函数进行调用。

6.7 其他函数成员

包含可执行代码的类成员统称为成员函数,前文介绍的方法是成员函数的主要种类,这一节介绍其他成员函数种类,构造函数、属性、索引、事件、操作符、以及解构函数。

下例这个泛型类List,演示了几种最常用的函数成员的用法。

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
public class List<T>
{
// Constant
const int defaultCapacity = 4;

// Fields
T[] items;
int count;

// Constructor
public List(int capacity = defaultCapacity)
{
items = new T[capacity];
}

// Properties
public int Count => count;

public int Capacity
{
get { return items.Length; }
set
{
if (value < count) value = count;
if (value != items.Length)
{
T[] newItems = new T[value];
Array.Copy(items, 0, newItems, 0, count);
items = newItems;
}
}
}

// Indexer
public T this[int index]
{
get
{
return items[index];
}
set
{
items[index] = value;
OnChanged();
}
}

// Methods
public void Add(T item)
{
if (count == Capacity) Capacity = count * 2;
items[count] = item;
count++;
OnChanged();
}
protected virtual void OnChanged() =>
Changed?.Invoke(this, EventArgs.Empty);

public override bool Equals(object other) =>
Equals(this, other as List<T>);

static bool Equals(List<T> a, List<T> b)
{
if (Object.ReferenceEquals(a, null)) return Object.ReferenceEquals(b, null);
if (Object.ReferenceEquals(b, null) || a.count != b.count)
return false;
for (int i = 0; i < a.count; i++)
{
if (!object.Equals(a.items[i], b.items[i]))
{
return false;
}
}
return true;
}

// Event
public event EventHandler Changed;

// Operators
public static bool operator ==(List<T> a, List<T> b) =>
Equals(a, b);

public static bool operator !=(List<T> a, List<T> b) =>
!Equals(a, b);
}

6.7.1 构造函数

C#同时支持类构造函数和实例构造函数,一个类首次加载,类成员函数会被调用,每创建一个类的实例,new操作符会在新实例上调用它的实例构造函数,以初始化当前实例。

构造函数没有返回值类型,函数名称和类名相同,子类不会继承父类的构造函数,如果有static修饰符,它就是一个类构造函数。如果没有static修饰符,它就是一个实例构造函数。

你可以重载实例构造函数,如果一个类没有实例构造函数,编译器会提供一个不接收参数的默认实例构造函数。

6.7.2 属性

属性和域都是带有类型的命名成员,访问一个属性和域的语法也相似,属性不代表存储位置,属性有访问器可以指定当属性被读取或写入时执行的语句。

定义属性和定义域一样,唯一不同的时属性的定义以一对花括号结束,并在花括号写明读、写访问器。而不是以分号结束。

根据读、写访问器的不同组合情况可将属性分为只读属性、只写属性、和读写属性。

你可以把读访问器看作一个没有参数但返回值类型是属性的类型的方法,当属性作为赋值表达式的右值时,或作为算术表达式的一个成员时,读访问器被调用以求出属性的值;同样,你可以写访问器看成一个没有返回值然而有一个类型为属性类型的参数的方法。当属性作为赋值表达式的左值,或应用在自增、自减表达式中时,写访问器被调用并用一个新值作为参数更新对应属性。

List类定义了Count和Capacity这两个属性,示例演示了属性的用法

1
2
3
4
List<string> names = new List<string>();
names.Capacity = 100; // Invokes set accessor
int i = names.Count; // Invokes get accessor
int j = names.Capacity; // Invokes get accessor

static、virtual、abstract、override修饰符同样可以作用于属性,一旦一个属性有virtual、abstract、override修饰符,这些修饰符作用的范围事实上是属性的访问器。

6.7.3 索引

索引为类带来数组似的访问方式,索引的定义跟属性类型,只不过索引的名字必须是this,并且名字后跟一对额外的方括号,并在里面声明下标参数列表,索引的访问器可以访问这些下标。索引也是可以重载的,只要他们的下表参数列表在形式上不同。

6.7.4 事件

类和示例利用事件机制提供通知,事件的定义方式跟属性类似,只不过要使用event关键字,并且事件成员的类型必须是一个delegate。

事件成员引用事件处理器,如果没有为一个事件指定事件处理器,这个事件成员的值就是null。

List定义了Changed这一个事件成员,Changed事件代表一个新元素加入列表。OnChanged方法触发Changed事件。触发事件实际上就是通过事件delegate调用事件处理器函数。C#没有专门设计触发事件的语法。

这个例子演示了C#事件机制,+=操作符为事件指定处理器,-=操作符为事件解除处理器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class EventExample
{
static int changeCount;
static void ListChanged(object sender, EventArgs e)
{
changeCount++;
}
public static void Usage()
{
List<string> names = new List<string>();
names.Changed += new EventHandler(ListChanged);
names.Add("Liz");
names.Add("Martha");
names.Add("Beth");
Console.WriteLine(changeCount); // Outputs "3"
}
}

可以给事件定义添加add、remove访问器,以适应需要控制事件底层存储的应用场景。

6.7.5 操作符

操作符定义了一个类的实例如何参与到表达式计算中,有三种操作符可以定义,一元操作符、二元操作符、转型操作符。操作符定义必须用public和static来修饰。

List重定义了==和!=这两个操作符,从而给了这两个操作符新的意义。下例演示如何使用这两个新的操作符比较两个列表是否相等。

1
2
3
4
5
6
7
8
9
List<int> a = new List<int>();
a.Add(1);
a.Add(2);
List<int> b = new List<int>();
b.Add(1);
b.Add(2);
Console.WriteLine(a == b); // Outputs "True"
b.Add(3);
Console.WriteLine(a == b); // Outputs "False"

6.7.6 解构函数

解构函数包含一个类实例占用的内存销毁之前执行的语句,析构函数不能有参数列表、作用域修饰符,也不能显式调用。垃圾回收机制会自动为类的实例调用它们的解构函数。

一个类实例的解构函数在何时、何线程调用都取决于垃圾回收器,因此是不确定的,如果有更好的解决方案,不要为一个类提供解构函数。

using语句是一个更好的对象解构方案。

7 结构体

结构体也能包含数据成员和函数成员,不过结构是值类型,因此它不需要对分配,一个结构变量直接存放自己的结构成员,而一个类变量存放的是指向类实例在堆上的引用。用户不能手动在结构上应用继承操作,所有结构都自动继承自ValueType,然后再继承自object。

结构特别适合用来表示在语义上以数据为主的小数据结构,复数、坐标系统中的点、键值对都是很适合使用结构的例子。用结构代替类很多时候会降低内存管理的开销。下面的程序申请了一个能够存储100个Point类的数组,然而,该语句要向内存管理系统申请101次内存,首先,为数组本身申请空间,然后,再为数组中的100个类实例分别申请它们所占用的空间。

1
2
3
4
5
6
7
8
9
public class PointExample
{
public static void Main()
{
Point[] points = new Point[100];
for (int i = 0; i < 100; i++)
points[i] = new Point(i, i);
}
}

如果把Point定义为结构

1
2
3
4
5
6
7
8
9
struct Point
{
public int x, y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}

同样的语句只需要向内存管理系统申请一次内存,即数组本身,100个结构直接连续存储在为数组申请的内存区域中。

调用结构的构造函数的方式也是new操作符,

对类来说,多个类变量可以引用同一个类实例,因此,对其中一个类变量进行的修改会影响另一个引用同一个类实例的类变量,然而,这种情况不会发生在结构变量上,因为结构是值类型,每个结构变量都有结构成员的一份独一拷贝。下面这段代码的执行结果取决于Point是类还是结构。

1
2
3
4
Point a = new Point(10, 10);
Point b = a;
a.x = 20;
Console.WriteLine(b.x);

如果Point是类,输出会是20,因为a、b事实上引用了同一个类实例。如果Point是结构,输出会是10,因为在第二行,b取得了a的一份拷贝,在此之后,a、b是相互独立的值类型变量。

前文的讨论也反射出结构的两个局限性,一是在结构上的赋值开销大于引用赋值开销,引用的赋值操作只拷贝如何找到右操作数引用的对象的信息。而结构的赋值要拷贝结构的所有成员,这会影响赋值和参数传递的效率。二是除了in,ref,out参数,我们没有办法创建结构的引用,这限制了结构的适用场景。

8 数组

数组可用来存储一定数量类型相同的变量,数组的变量称作数组元素,数组的类型也决定了数组元素的类型,使用对应的下标来访问数组中的每个元素。

数组是引用类型,数组变量表示使用new操作符在堆上动态创建的数组实例,使用new操作符创建一个数组时,你需要指定数组的长度,一个数组的实例一旦创建,其长度无法再更改。一个数组的有效下标从0开始,到数组长度-1,new操作符会自动为数组的每个元素初始化为他们的默认值,对数值类型来说,它们的默认值是0,对引用类型来说,他们的默认值是null。

多维数组

创建、初始化、并打印一个长度为10的int类型元素的数组的示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
class ArrayExample
{
static void Main()
{
int[] a = new int[10];
for (int i = 0; i < a.Length; i++)
{
a[i] = i * i;
}
for (int i = 0; i < a.Length; i++)
{
Console.WriteLine($"a[{i}] = {a[i]}");
}
}
}

最常用的数组形式是上面的例子中定义的一维数组,C#也支持多维数组,数组的维度,也叫数组的级(rank)。访问1维数组需要一个下标,访问二维数组需要两个下标,访问三维数组需要三个下标,以此类推。下例分别创建一个一维数组、二维数组、和三维数组。

1
2
3
int[] a1 = new int[10];
int[,] a2 = new int[10, 5];
int[,,] a3 = new int[10, 5, 2];

8.2 不规则数组

数组的元素类型可以是任意类型,有时候它是另一个数组,你也可以把二维数组看成是一个元素类型是数组的一维数组,但是,这些数组的长度都是一致的,真正定义为元素类型为数组的一维数组存储的每个数组的长度可以不同,我们把这种数组称作不规则数组。

1
2
3
4
int[][] a = new int[3][];
a[0] = new int[10];
a[1] = new int[5];
a[2] = new int[20];

8.3 初始化列表

new操作符允许你在创建数组时为它指定一个初始化列表,初始化列表是用逗号分隔并用花括号括起来的一组表达式。下例演示了初始化列表的用法。

1
int[] a = new int[] {1, 2, 3};

在上面的例子中,可以看到对new操作符省略了数组长度, 这是因为new操作符可以根据初始化列表的元素个数自动推算数组长度, 依据这个思路, 连数组元素类型也是可以省略的, 请看下例.

1
int[] a = {1, 2, 3};

以上两个创建和初始化数组的例子都等同于以下代码.

1
2
3
4
5
int[] t = new int[3];
t[0] = 1;
t[1] = 2;
t[2] = 3;
int[] a = t;

9 接口

接口定义了实现它的类或结构必须提供的成员的约定, 你可以在一个接口定义里声明方法, 属性, 事件, 以及索引.

9.1 多继承

一个接口可以继承一个或多个其他接口, 下例中, IComboBox同时继承ITextBox和IListBox接口.

1
2
3
4
5
6
7
8
9
10
11
12
13
interface IControl
{
void Paint();
}
interface ITextBox: IControl
{
void SetText(string text);
}
interface IListBox: IControl
{
void SetItems(string[] items);
}
interface IComboBox: ITextBox, IListBox {}

类和结构可以同时实现多个接口, 下例中, EditBox类同时实现IControl和IDataBound接口.

1
2
3
4
5
6
7
8
9
interface IDataBound
{
void Bind(Binder b);
}
public class EditBox: IControl, IDataBound
{
public void Paint() { }
public void Bind(Binder b) { }
}

9.2 向接口转换

一个实现了I接口的类或结构的实例可以隐式转换为I接口类型的变量.

1
2
3
EditBox editBox = new EditBox();
IControl control = editBox;
IDataBound dataBound = editBox;

如果想把一个不能确定是否实现了接口I的类或对象的实例转换为I接口类型的变量, 就要借助显式的动态类型转换, 下例中, 因为obj实际上引用了一个已经实现了IControl和IDataBound接口的类的实例, 所以, 动态类型转换成功了.

1
2
3
object obj = new EditBox();
IControl control = (IControl)obj;
IDataBound dataBound = (IDataBound)obj;

9.3 显式成员实现

由于EditBox实现了IControl和IDataBound接口, 所以你在EditBox类实例上调用分别规定于IControl和IDataBound的Paint和Bind方法是没有问题的, 但你有时候可能希望仅在已经知道类实现了接口I的场景中暴露在接口I中定义的成员, 所以C#支持显式接口成员实现, 在实现接口成员时, 把成员名书写为全限定格式就在实现类中隐藏了这个接口成员, 只有把实现类的实例转换为一个接口才可以在这个接口变量上访问实现类实现的的对应接口成员.

下例演示了全限定实现成员函数的用法

1
2
3
4
5
public class EditBox: IControl, IDataBound
{
void IControl.Paint() { }
void IDataBound.Bind(Binder b) { }
}

下例演示了如何访问在实现类中隐藏的接口成员.

1
2
3
4
EditBox editBox = new EditBox();
editBox.Paint(); // Error, no such method
IControl control = editBox;
control.Paint(); // Ok

10 枚举类型

枚举是一种命名常量类型, 你可以在枚举类型的定义中指定这个枚举类型的变量可以引用哪些命名常量.

下例定义了一个名为Color的枚举类型, 包含Red, Green, Blue三个常量.

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
using System;
enum Color
{
Red,
Green,
Blue
}
class EnumExample
{
static void PrintColor(Color color)
{
switch (color)
{
case Color.Red:
Console.WriteLine("Red");
break;
case Color.Green:
Console.WriteLine("Green");
break;
case Color.Blue:
Console.WriteLine("Blue");
break;
default:
Console.WriteLine("Unknown color");
break;
}
}
static void Main()
{
Color c = Color.Red;
PrintColor(c);
PrintColor(Color.Blue);
}
}

10.1 潜在类型

每个枚举类型都对应一种潜在的数值类型, 默认情况是int, 枚举类型的潜在类型决定了枚举类型的变量的存储方式以及取值范围, 可以赋值给枚举类型变量的值不仅仅是枚举类型自己定义的成员, 换句话说, 任何枚举类型的潜在类型可以接收的值都可以经转换赋值给枚举类型的变量, 成为其有效的枚举值.

一个显式指定了枚举类型的潜在类型为sbyte的枚举类型定义示例如下:

1
2
3
4
5
6
enum Alignment: sbyte
{
Left = -1,
Center = 0,
Right = 1
}

10.2 枚举值

如果不为枚举类型的常量成员指定一个范围在枚举类型的潜在类型中的确定的值, 常量会自动获得前一个成员的值+1的数值(如果这样的成员是枚举类型中的第一个成员, 它会自动获得数值0).

如下例所示, 枚举类型和数值类型之间可以相互转换.

1
2
int i = (int)Color.Blue;    // int i = 2;
Color c = (Color)2; // Color c = Color.Blue;

10.3 枚举变量的默认值

一个枚举变量的默认值是数值0转换为枚举变量对应的枚举值, 数值0也可以隐式转换为任何枚举类型.

1
Color c = 0;

11 Delegates

Delegate类型可以引用返回值类型和参数列表确定的一种方法, 有了Delegate类型, 你就可以把方法当做一个变量或者函数参数来使用, 对于那些熟悉函数指针的用户来说, Delegate是C#中面向对象和类型安全的函数指针.

下例演示如何定义和使用一个名为Function的Delegate变量.

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
using System;
delegate double Function(double x);
class Multiplier
{
double factor;
public Multiplier(double factor)
{
this.factor = factor;
}
public double Multiply(double x)
{
return x * factor;
}
}
class DelegateExample
{
static double Square(double x)
{
return x * x;
}
static double[] Apply(double[] a, Function f)
{
double[] result = new double[a.Length];
for (int i = 0; i < a.Length; i++) result[i] = f(a[i]);
return result;
}
static void Main()
{
double[] a = {0.0, 0.5, 1.0};
double[] squares = Apply(a, Square);
double[] sines = Apply(a, Math.Sin);
Multiplier m = new Multiplier(2.0);
double[] doubles = Apply(a, m.Multiply);
}
}

Function类型的实例可以引用人和返回值类型为double并且接收一个double类型参数的方法, Apply方法将不同的Function应用在数组a上, 并把每个元素的对应结果存放在另一个数组result作为返回值返回, Main函数调用Apply对数组a应用了三种不同的函数.

Delegate不仅能引用类方法, 也能引用实例方法和匿名方法, 在引用实例方法的情形, 不仅特定实例的对应方法被Delegate引用, 实例本身也被Delegate引用了, 通过Delegate引用调用实例方法, 实例方法的this指针即被Delegate引用的实例. 在引用匿名方法的情形, 由于一个匿名方法可以访问父方法中的局部变量, 上例中的倍数函数可以写成更简单的形式.

1
double[] doubles =  Apply(a, (double x) => x * 2.0);

Delegate并不知道被引用的方法属于哪个类, Delegate能否引用一个类方法仅仅取决于该方法的返回值类型和参数类型是否跟Delegate定义相匹配.

12 特征

修饰符可以修饰C#中的类型, 成员, 和其他实体, 例如, 一个方法的可见范围可以通过public, protected, internal, private来指定, 通过定义和使用属性, 用户能够为这些实体附加自定义类型的描述信息并在运行时取得.

下面的例子定义了一个名为HelpAttribute的属性, 该属性能够附加在程序中的实体上并为该实体提供帮助文档链接.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;

public class HelpAttribute: Attribute
{
string url;
string topic;
public HelpAttribute(string url)
{
this.url = url;
}

public string Url => url;

public string Topic {
get { return topic; }
set { topic = value; }
}
}

所有属性都继承自在标准库中定义的Attribute类, 在一对方括号中写上属性名并跟上参数列表放置在任何实体之前就向这个实体附加了一个特定属性, 如果属性名以Attribute结束, 例如上面定义的HelpAttribute, 在使用时可以简写属性名为Help. 示例如下:

1
2
3
4
5
6
7
[Help("https://docs.microsoft.com/dotnet/csharp/tour-of-csharp/attributes")]
public class Widget
{
[Help("https://docs.microsoft.com/dotnet/csharp/tour-of-csharp/attributes",
Topic = "Display")]
public void Display(string text) {}
}

这个例子分别为Widget类和Widget类中的Display方法附加了HelpAttribute属性, 属性的构造方法规定了在附加一个属性时必须提供的信息, 其他额外的信息则通过引用pulibc的读写属性提供.

附加在实体上的特征可以在运行期间通过反射机制取得, 该机制保证在一个特征对象取回之前, 在源程序中指定的参数会作为构造函数的参数调用特征的构造函数, 额外参数也会通过属性设置好, 最后一个例子演示得是从Widget对象上取回附加在对象本身和Display方法上的Help特征.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Type widgetType = typeof(Widget);

//Gets every HelpAttribute defined for the Widget type
object[] widgetClassAttributes = widgetType.GetCustomAttributes(typeof(HelpAttribute), false);

if (widgetClassAttributes.Length > 0)
{
HelpAttribute attr = (HelpAttribute)widgetClassAttributes[0];
Console.WriteLine($"Widget class help URL : {attr.Url} - Related topic : {attr.Topic}");
}

System.Reflection.MethodInfo displayMethod = widgetType.GetMethod(nameof(Widget.Display));

//Gets every HelpAttribute defined for the Widget.Display method
object[] displayMethodAttributes = displayMethod.GetCustomAttributes(typeof(HelpAttribute), false);

if (displayMethodAttributes.Length > 0)
{
HelpAttribute attr = (HelpAttribute)displayMethodAttributes[0];
Console.WriteLine($"Display method help URL : {attr.Url} - Related topic : {attr.Topic}");
}

Console.ReadLine();
如果这篇文章对您有用,可以考虑打赏:)