前言

我本来之前打算出一个C#入门到中级的相关文章教程的,但是考虑到太简单了我实在是懒得写了,可以参考很多网上的教程来学习,对于现在的我来说,我更加想记录一下C#的高级教程,因为对于C#的高级用法网上的教程并没有太多或者说太详细或者系统,也算是记录我自己的C#进阶路程。

前排提醒:查看学习本文章,需要掌握一定的C#基础。

引用说明:部分内容学习自网络和《深入理解C#》

泛型

C#入门的时候,就大概说明过泛型的用法和功能,它可以在不需要事先知道要使用的类型,即可以在不同位置表示相同的类型。在C# 2之前(即.NET 1),泛型主要用于集合。如今,泛型以及广泛应用到各个部分,其中使用较多的如下几项:

  • 集合
  • 委托(尤其是LINQ中的应用)
  • 异步代码Task<T>表示该方法将返回一个类型为T的值)
  • 可空值类型

如下实例将会从集合角度来展示泛型的优势。

泛型诞生前的集合

.NET 1有如下三大类集合:

  • 数组:语言和运行时直接支持数组。数组的大小在初始化的时候就确定了。

  • 普通对象集合:API中的值通过System.Object描述。尽管诸如索引器和和foreach语句这些语言特性可以应用在普通对象结合,但语言和运行时并未对其提供专门的支持。

    ArrayListHashtable是更常见的两种对象集合。

  • 专用类型集合:API中描述的值具有特定类型,集合只能用于该类型。例如:StringCollection是保存字符串的集合,虽然其 API 看起来和ArrayList类似,但是它只能接收String类型元素,而不能接收Object类型。

数组和专用类型集合都属于静态类型,因此 API 可以阻止讲错误类型的值添加到集合中。在集合中取值也无需手动转换类型

现在假设有一个名为GenerateName,该方法用于创建一个String类型的集合,此外还有一个名为PrintNames的方法,它可以把该集合的所有元素显示出来。我们分别用上面所示的三种集合(数组,ArrayList以及StringCollection)来实现,然后对比三者的优劣。

使用数组创建并打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建数组
static string[] GenerateNames()
{
string[] names = new string[4];
names[0] = "Gamma";
names[1] = "Vlissides";
names[2] = "Johnson";
names[3] = "Helm";
return names;
}
//输出数组
static void PrintNames(string[] names)
{
foreach(string name in names)
{
Console.WriteLine(name);
}
}

如上代码中,并没有特意使用数组初始化器来创建数组,而是模拟了逐个获取names元素的场景,例如读取文件内容。另外,在创建数组的时候应当为其创建合适的大小。读文件这种情况就需要事先知道文件中有多少个名字,才能在创建数组的时候为他分配大小。或者采用更复杂的,先创建一个数组,如果初始数组被填满,则创建一个更大的数组,如此反复,直到所有元素添加完毕。当然最后如果数组依然有空间,可以再创建一个合适的数组来讲元素复制过去。

诸如追踪当前集合大小,重新分配数组等重复性操作,都可以用一个类型封装起来,使用ArrayList即可实现

使用 ArrayList 创建并打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建ArrayList对象
static ArrayList GenerateNames()
{
ArrayList names = new ArrayList();
names.Add("Gamma");
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}
//输出对象内容
static void PrintNames(ArrayList names)
{
foreach(string name in names)
{
Console.WriteLine(name);
}
}

在创建ArrayList时,无须事先知道names的个数,因此GenerateNames()方法得以简化。不过,和数组一样存在一个相同的问题:使用ArrayList依旧无法确保非String类型的值被添加进来因为ArrayList.Add方法的参数类型是Object

此外,PrintNames方法看似是类型安全的,但是如果该ArrayList中包含一个WebReaquest类型的值,由于name变量声明为string类型,因此foreach循环每次都会对集合中的元素做隐式类型转换,把object转换为string类型。最终,从WebRequeststring类型的转换抛出InvalidCastException

虽然使用ArrayList解决了数组大小的问题,但是缺引出了类型安全的问题,有什么办法可以二者兼顾?

使用 StringCollection 创建并打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建ArrayList对象
static StringCollection GenerateNames()
{
StringCollection names = new StringCollection();
names.Add("Gamma");
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}
//输出对象内容
static void PrintNames(StringCollection names)
{
foreach (string name in names)
{
Console.WriteLine(name);
}
}

注:需要引用using System.Collections.Specialized命名空间

除了把ArrayList都替换成了StringCollection之外,其他都和ArrayList的代码一致。但是使用了StringCollection这样与其他通用类型集合无区别,只是其只负责处理String类型的元素。**StringCollection.Add方法参数类型是String**,因此不能向其添加非String类型的元素,这样就保证了在显示names的时候不会出现非String类型的错误了。

如果只是在处理String类型的情况下,StringCollection确实是不二之选。但是如果使用的是其他的类型集合,要么就需要寄希望于.NET Framework已经提供了所需的集合类型,要么就需要自己写一个了。但是由于类型的需求非常普遍,因此就有了System.Collections.CollectionBase这个抽象类,用于减少上述工作量

使用专用类型集合可以解决前面提到的两个问题(数组大小和类型安全),但是创建更多的额外类型,代价实在太高了。另外,编译实际,程序集大小,JIT 耗时,代码段内存都会产生额外的内存消耗,最关键的是维护这些集合需要额外的人力成本。

即使忽略上述成本,也没办法避免代码灵活性的降低:无法以静态方式编写适用于所有集合类型的通用方法,也无法把集合元素的类型用于参数或者返回值类型。假设需要创建一个方法,该方法把一个集合的前 N 项元素复制到另一个新的集合中,之后返回该集合。如果使用ArrayList,那就等同于舍弃了静态类型的优势。如果传入StringCollection,那么返回值类型也必须是StringCollectionString类型成了方法输入的要素,于是返回值也必须是String类型。**C# 1对这个问题束手无策,于是泛型登场了。**

泛型诞生

解决上述问题的办法就是泛型List<T>List<T>是一个集合,其中 T 表示集合元素的类型,在如上的例子中,string就是这个 T ,因此List<string>就可以替换所有StringCollection。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建ArrayList对象
static List<string> GenerateNames()
{
List<string> names = new List<string>();
names.Add("Gamma");
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}
//输出对象内容
static void PrintNames(List<string> names)
{
foreach (string name in names)
{
Console.WriteLine(name);
}
}

List<T>解决了前面说到的所有问题:

  • 与数组不同,**List<T>无须在创建前获取集合的大小**
  • ArrayList不同,在对外提供的 API 之中,一切表示元素类型都可以用 T 来表示。这样我们就能知道List<string>的集合只能包含 string类型的引用。如果添加了错误的类型,则编译时会报错。
  • StringCollection等类型不同,**List<T>兼容所有类型**,省去了诸多烦恼。

类型形参与类型实参

形参(parameter)和实参(argument)的概念,比C#泛型概念出现的还要早。

声明函数时用于描述函数输入数据的参数称为形参,函数调用时实际传递给函数的参数称为实参。

image-20220622194416632

实参的值相当于方法形参的初始值,而泛型涉及两个参数概念:类型参数(type parameter)和类型实参(type argument),相当于把普通形参和实参的思想用在了表示类型信息上。在声明泛型类或者泛型方法时,需要把类型形参写在类名或者方法名称之后,并用尖括号<>包围。之后在声明体中,就可以像普通类型一样使用该类型形参了。

1
2
3
4
5
//类型形参
List<T>

//类型实参
List<string>

当使用如上List<string>时,如果使用其的Add()方法,其函数原型如下:

1
public void Add(T item);

在 Visual Studio 中输入List.Add()方法,智能补全会提示我们应该是string类型,当传入不合法类型时,会引发编译错误。

泛型也可以用于方法,在方法声明中给出类型形参,之后就可以在方法中使用这些类型参数了。

如下解决了之前的一个问题,以静态类型的方式把一个集合的前 N 个元素复制到另一个新的集合中。

静态类型如果传入错误的类型编译报错,且不需要进行类型转换

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
class CopyArray
{
//复制并返回数组
public static List<T> CopyArrayNum<T>(List<T> list, int num)
{
List<T> newList = new List<T>();
for(int i = 0; i < num; i++)
{
newList.Add(list[i]);
}
return newList;
}
//输出数组值
public static void PrintArrayNum<T>(List<T> list)
{
foreach(T value in list)
{
Console.WriteLine(value);
}
}
}


//主程序口
static void Main(string[] args)
{
List<string> list1 = new List<string>() { "你好", "世界", "!!!" };
List<string> list2 = CopyArray.CopyArrayNum(list1, 2);
CopyArray.PrintArrayNum<string>(list2);
Console.WriteLine("------------");
CopyArray.PrintArrayNum<string>(list1);
Console.ReadKey();
}

运行结果:

image-20220622203934611

同样的,当声明有基类或者接口时,泛型形参也可以作为基类或者接口的泛型实参,比如下的泛型类和泛型接口:

1
class Generics<T>:IEnumerable<T>{}

实际上要实现泛型接口,需要实现其成员,不是如上面这么简化

泛型类型和泛型方法的度

泛型类型或者泛型方法可以声明多个类型形参只需要在尖括号内用逗号把它们隔开即可,代码示例:

1
class Test<T1, T2>{}

泛型度(arity)是泛型声明中类型形参的数量。我们可以将非泛型的度理解为零。

泛型度是区分同名泛型声明的有效指标。比如前面提到的C# 2中的泛型接口IEnumerable<T>,它和.NET 1.0中非泛型接口IEnumerable就不属于同一类型。同样的,可以根据不同度来编写同名重载方法,代码示例:

1
2
3
void Method() { }
void Method<T>() { }
void Method<T1, T2>() { }

需要注意的是,泛型方法重载的定义区分是根据泛型的度,而不是泛型的名称,代码示例:

1
2
3
4
//这么写编译器会报错的
//因为同一方法重载前提是两者的度不一样,和泛型类型形参的名称无关
void Method<T1>() { }
void Method<T2>() { }

另外,泛型的形参也是不可以相同的,代码示例:

1
2
//两个形参相同,编译器依旧会报错
void Method<T, T>() { }

泛型的适用范围

并非所有类型或者类型成员适合用泛型。对于类型,很好区分,因为可供声明的类型比较有限;对于枚举类型不能声明泛型,而类,结构体,接口以及委托这些可以声明为泛型类型

判断一个声明是否是泛型声明的唯一标准,是看它是否引入了新的类型形参

方法和类型可以是泛型,当时以下类型成员不能是泛型:

  • 字段
  • 属性
  • 索引器
  • 构造器
  • 事件
  • 终结器

下面举一个貌似是泛型但是实际不是的例如,代码示例:

1
2
3
4
public class Test<TItem>
{
private readonly List<TItem> items = new List<TItem>();
}

items是类型为List<TItem>的一个字段,它将TItem作为List<T>的类型实参。TItem是由Test类声明引入的类型形参,而不是由items声明本身引入的。

方法类型实参的类型推断

对于如上类型形参和实参中的代码举例,摘出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//复制并返回数组
public static List<T> CopyArrayNum<T>(List<T> list, int num)
{
List<T> newList = new List<T>();
for(int i = 0; i < num; i++)
{
newList.Add(list[i]);
}
return newList;
}
//输出数组值
public static void PrintArrayNum<T>(List<T> list)
{
foreach(T value in list)
{
Console.WriteLine(value);
}
}

其泛型方法声明原型如下:

1
public static List<T> CopyArrayNum<T>(List<T> list, int num)

现在,我们在main方法中声明一个List<int>类型的变量numbers,并将该变量作为如上泛型方法CopyArratNum()的调用实参(参数)传入,代码示例:

1
2
List<int> numbers = new List<int>();
CopyArray.CopyArrayNum<int>(numbers, 2);

如上代码其实可以省略为如下:

1
2
List<int> numbers = new List<int>()
CopyArray.CopyArrayNum(numbers, 2);

从编译器之后生成的 IL 代码的角度来说,这两种写法是完全相同的。这里并不需要明确指出给定的泛型实参int,因为编译器可以根据传入的参数来判断泛型类型

编译器只能推断出传递给方法的类型实参,但是推断不出返回值的类型实参。对于返回值的类型实参,要么显式的表示出来,要么全部省略掉。

尽管类型判断只能用于方法,但是它可以简化泛型类型实例的创建,代码示例:

1
2
3
4
5
6
7
8
9
10
11
class Tuple
{
public static Tuple<T1> Create<T1>(T1 item1)
{
return new Tuple<T1>(item1);
}
public static Tuple<T1,T2> Create<T1,T2>(T1 item1,T2 item2)
{
return new Tuple<T1,T2>(item1,item2);
}
}

如上写法看似没有什么意义。前面说过,泛型类型推断并不适用于构造器,这么做旨在将对象构造的同时利用方法的类型推断。如果直接使用对象构造器实现会比较麻烦,代码示例:

1
new Tuple<int,string,int>(10,"x",20);

但是如果使用上述静态方法配合类型推断,代码就会简单很多,代码示例:

1
Tuple.Create(10,"x",20);

使用这个技巧可以简化部分代码。

通常来说,使用泛型会遇到如下三种情况:

  • 类型推断成功,并且取得预期成果
  • 类型推断成功,但是没有取得预期成果。此时,只需要显式指定类型实参或者对某些实参转换类型即可。例如上面的Tuple.Create方法,如果目标结果是Tuple<int,object,int>类型的元组,则显示的指定类型实参:Tuple.Create<int,object,int>(10,"x",20);或者直接使用构造器new Tuple<int,object,int>(...);或者调用Tuple.Create(10,(object)"x",20);
  • 类型推断在编译时出错。有时候只需要转换参数类型就能解决。例如调用Tuple.Create(null,50)就会出错,因为null本身不包含任何类型的信息,改写成Tuple.Create((string)null,50)即可。

类型约束

前面提到的类型参数都是没有经过约束的,它们可以表示任意类型。有的时候我们需要对于某个类型参数需要它只限于特定的类型,这就引出了类型约束的概念。

在泛型类型或者泛型方法中什么类型形参时,可以使用类型约束来限定哪些类型可以作为类型实参

假设我们需要一个方法来实现某个功能,该方法存在于IObject接口里,我们传入参数使用,现在如果我们定义需要传入特定类型的参数如下所示:

1
public void OutPut(List<Cube> items);

这样如果有一个Sphere类型也继承自IObject接口,但是没有办法传入参数,编译器会报错,但是我们如果使用List<T>类型则没有办法约束传入的参数是否适合我们的这个方法,这个时候我们就需要使用类型约束就可以将传入的类型实参,代码示例:

1
staic void OutPut<T>(List<T> items) where T : IObject

这样就约束了需要传入的类型实参必须是实现继承了IObject接口的对象。

类型约束不仅仅适用于接口,还可以约束如下类型:

  • 引用类型约束(where T : class):类型实参必须是一个引用类型

    class关键词容易引起误解,它表示任何引用类型,包括接口和委托。

  • 值类型约束(where T : struct):类型实参必须是非可空值类型(结构体类型或者枚举类型)。

  • 构造器约束(where T : new()):类型实参必须是公共的无参构造器。该约束保证了可以通过new T()来创建一个T类型的实例。

  • 转换约束(where T : SomeType):这里的SomeType可以是类,接口或者其他类型形参,如上我所使用的IObjecy

类型约束可以组合使用,一般组合规则比较复杂,例如:

1
2
3
void Test<T1,T2>()
where T1 : IObejct,class
where T2 : new(),class

泛型类型初始化与状态

前面说到类型约束的时候,提到过:这样如果有一个Sphere类型也继承自IObject接口,但是没有办法传入参数,编译器会报错,因为每个不同泛型类型的对象都会被当作不同类型处理,代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//定义一个泛型类
class Tests<T>
{
static int num;
public void Plus()
{
num++;
}
public void PrintNum()
{
Console.WriteLine(num);
}
}


//main函数入口
Tests<int> tests1 = new Tests<int>();
Tests<string> tests2 = new Tests<string>();
tests1.Plus();
tests1.Plus();
tests1.PrintNum();
tests2.Plus();
tests2.PrintNum();
Console.ReadKey();

运行结果:

image-20220624234549579

你会发现对于这两个类,系统将其各自的静态字段num执行了不同次数的加法,说明Tests<int>Tests<string>本质上是两个类型。

这个问题还可以进一步复杂化,将泛型进行嵌套,代码示例:

1
2
3
4
5
6
7
8
class A<T>
{
class B<T>
{

}
}

对于上述嵌套,如果使用intstring两个作为类型实参,如下的每种组合得到的都是一个独立的对象。

  • A<int>.B<int>
  • A<string>.B<int>
  • A<int>.B<string>
  • A<string>.B<string>

上述情况基本见不到,但是此处仅作说明表示这在编译器中都是独立的对象。

End

如上就是泛型的全部内容了,我跳过了defaulttypeof关键词的说明,我打算把它们单独写一起会更加的条理化一些。**泛型的出现也引出了可空值类型,即null**,说实话我不太想去写关于null即可空值类型的相关部分的,对于这个来说,我更加像写一写关于C#多线程的说明。