C# 学习笔记(五)
接口
什么是接口
接口就是指定一组函数成员而不实现它们的引用类型。
对于下面的示例,只要传入的是 CA 类型的对象,PrintInfo
方法就能正常工作。但如果是 CB 类型的对象就不行了。我们假设 PrintInfo
方法中的算法非常有用,我们想用它操作不同类的对象。我们希望能创建一个能够成功传入 PrintInfo
的类,并且不管该类是什么样的结构,PrintInfo
都能正常处理。于是就有了接口。
class CA
{
public string Name;
public int Age;
}
class CB
{
public string First;
public string Last;
public double PersonsAge;
}
class Program
{
static void PrintInfo(CA item)
{
Console.WriteLine($"Name: {item.Name}, Age: {item.Age}");
}
static void Main()
{
CA a = new CA() { Name = "John", Age = 30 };
PrintInfo(a);
}
}
下面的示例就是使用接口来解决这个问题。
- 首先,声明一个
IInfo
接口,包括两个方法 - 类 CA 和 CB 实现了这个接口(将其放到基类列表),并实现了该接口所需的两个方法
interface IInfo
{
string GetName();
string GetAge();
}
class CA : IInfo
{
public string Name;
public int Age;
public string GetName()
{
return Name;
}
public string GetAge()
{
return Age.ToString();
}
}
class CB : IInfo
{
public string First;
public string Last;
public double PersonsAge;
public string GetName()
{
return $"{First} {Last}";
}
public string GetAge()
{
return PersonsAge.ToString();
}
}
class Program
{
static void PrintInfo(IInfo item)
{
Console.WriteLine($"Name: {item.GetName()}, Age: {item.GetAge()}");
}
static void Main()
{
CA a = new CA() { Name = "John", Age = 30 };
CB b = new CB() { First = "Jane", Last = "Doe", PersonsAge = 33 };
PrintInfo(a);
PrintInfo(b);
}
}
使用 IComparable 接口
Array.Sort
可以对数组元素进行排序,但是对于自定义类型的元素,就没法直接进行排序了,Array.Sort
其实是依赖于一个叫做 IComparable
的接口,它声明在 BCL 中,包含唯一的方法 CompareTo
,声明如下
public interface IComparable
{
int CompareTo(object obj);
}
CompareTo
方法应该返回以下几个值
- 负数值 如果当前对象小于参数对象
- 正数值 如果当前对象大于参数对象
- 零 如果两个对象在比较时相等
Sort
的算法依赖于使用元素的 CompareTo
方法来决定两个元素的次序。int
类型实现了 IComparable
,而自定义类型 MyClass
没有,因此直接使用会抛出异常。我们可以通过让类实现 IComparable
来支持 Sort
方法。
class MyClass : IComparable
{
public int TheValue;
public int CompareTo(object obj)
{
MyClass other = (MyClass)obj;
if (TheValue < other.TheValue)
return -1;
if (TheValue > other.TheValue)
return 1;
return 0;
}
}
class Program
{
static void PrintOut(string s, MyClass[] mc)
{
Console.Write(s);
foreach (var m in mc)
Console.Write($"{m.TheValue} ");
Console.WriteLine();
}
static void Main()
{
var MyInt = new[] { 20, 4, 16, 9, 2 };
var mcArr = new MyClass[5];
for (int i = 0; i < mcArr.Length; i++)
{
mcArr[i] = new MyClass();
mcArr[i].TheValue = MyInt[i];
}
PrintOut("Initial Order: ", mcArr);
Array.Sort(mcArr);
PrintOut("Sorted Order: ", mcArr);
}
}
// Initial Order: 20 4 16 9 2
// Sorted Order: 2 4 9 16 20
声明接口
- 接口声明不能包含以下成员
- 数据成员
- 静态成员
- 接口声明只能包含如下类型的非静态成员函数
- 方法
- 属性
- 事件
- 索引器
- 这些函数成员的声明不能包含任何实现代码,必须使用分号代替每一个成员声明的主体。
- 按照惯例,接口名称必须从大写的
I
开始 - 与类和结构一样,接口声明也可以分隔成分部接口声明
- 接口声明可以有任何访问修饰符:
public
、protected
、internal
或private
- 然后接口成员是隐式
public
的,不允许有任何访问修饰符包括public
实现接口
- 如果类实现了接口,它必须实现接口的所有成员
- 如果类派生自基类并实现了接口,基类列表中,基类名称必须放在所有接口之前,基类只能有一个,实现的接口可以有多个
接口是引用类型
接口其实类似于抽象类,我们可以通过强制类型转换将类对象引用转换为接口类型,来获取指向这个接口的引用,此时就只能调用这个接口上的成员了。
接口和 as 运算符
我们可以通过强制类型转换来获取对象的接口引用,另一个更好的方式就是使用 as 运算符,类型转换如果失败会报错,as 的话只会返回一个 null
ILiveBirth b = a as ILiveBirth; // 跟 cast: (ILiveBirth) a 一样
实现多个接口
和实现单个区别不大,就是基类列表里添加更多的接口,并全部实现对应的成员即可
实现具有重复成员的接口
如果有多个接口的成员包含相同的签名和返回类型,只要实现了一个,自然就同时满足了每一个的需求
多个接口的引用
如果一个类实现了多个接口,那么自然可以获取到每一个接口对应的独立引用
派生成员作为实现
实现接口的类可以从它的基类继承实现的代码
interface IIfc1
{
void PrintOut(string s);
}
class MyBaseClass
{
public void PrintOut(string s)
{
Console.WriteLine($"Calling Through: {s}");
}
}
class Derived : MyBaseClass, IIfc1
{
}
class Program
{
static void Main()
{
Derived d = new Derived();
d.PrintOut("object.");
}
}
显式接口成员实现
我们可以一个成员实现多个接口,也可以为每个接口分离实现,这种情况下,我们可以创建显式接口成员实现。
class MyClass: IIfc1, IIfc2
{
void IIfc1.PrintOut(string s)
{...}
void IIfc2.PrintOut(string s)
{...}
}
这种情况下我们只能通过接口引用来调用对应的方法,无法再通过类实例来调用方法。
同样的,类的其他成员也无法直接访问 PrintOut
方法,如果需要,只能将 this
指针强制转换为对应的接口,来调用
接口可以继承接口
和类的继承形式一样,只不过可以继承任意数量的接口
转换
什么是转换
和 C++ 的类型转换概念相同,转换前后源值不变,只是类型发生了改变
隐式转换
有些类型的转换不会丢失数据或精度,例如将 8 位的值转换为 16 位是非常容易的,而且不会丢数据
- 语言会自动做这些转换,这叫隐式转换
- 从位数少的源类型转换位位数多的目标类型时,目标类型中多出来的位需要用 0 或 1 填充
- 当从更小的无符号类型转换为更大的无符号类型时,目标类型多出来的最高位都以 0 进行填充,这叫零扩展
- 对于有符号来说,额外的最高位用源表达式的符号位进行填充,这叫做符号扩展
显式转换和强制转换
除了上面这种情况,其他的类型转换就可能会丢失数据,或是导致数据溢出,产生其他值,此时 C# 是不会自动进行转换的,因此需要显式转换,或者说强制转换。之前已经用过很多次了,和 C 语言风格的强制类型转换没什么区别
转换的类型
- 除了标准的转换,还可以为用户自定义类型定义隐式转换和显式转换
- 还有一个预定义的转换类型,叫做装箱,它可以把任何值类型转换为
object
类型System.ValueType
类型
- 拆箱可以将一个装箱的值转换为原始类型
数字的转换
任何数字类型都可以转换为其他数字类型,只不过一些是隐式的,一些是显式的
隐式数字转换
溢出检测上下文
C# 允许我们选择运行时是否应该在进行类型转换时检测结果溢出。这将通过 checked 运算符和 checked 语句实现
- 代码片段是否被检查称为溢出检测上下文
- 如果我们指定一个表达式或一段代码为 check,CLR 会在转换传送溢出时,抛出一个
OverflowException
异常 - 如果代码不是 checked,转换会继续而不管是否产生溢出
- 如果我们指定一个表达式或一段代码为 check,CLR 会在转换传送溢出时,抛出一个
- 默认的溢出检测上下文是不检查
checked 和 unchecked 运算符
ushort sh = 2000;
byte sb;
sb = unchecked((byte)sh); // 大多数数重要的位丢失了
Console.WriteLine($"sb: {sb}");
sb = checked((byte)sh); // 抛出异常
Console.WriteLine($"sb: {sb}");
// sb: 208
// Unhandled exception. System.OverflowException: Arithmetic operation resulted in an overflow.
// at Program.Main() in Program.cs:line 11
checked 和 unchecked 语句
运算符用于圆括号内的单个表达式,而语句用于一块代码的所有转换
ushort sh = 2000;
byte sb;
checked
{
unchecked
{
sb = (byte)sh;
Console.WriteLine(sb);
}
sb = (byte)sh;
Console.WriteLine(sb);
}
// 208
// Unhandled exception. System.OverflowException: Arithmetic operation resulted in an overflow.
// at Program.Main() in Program.cs:line 16
显式数字转换
- 整数类型的转换,上面已经提过了,checked的情况下溢出会抛出异常,unchecked 下溢出会丢弃额外的高位,不足会扩展。
- 和 C++ 一样,浮点类型(float 和 double) 转到整数类型,值会舍去小数,截断为最接近的整数,同样的 checked 情况下溢出会抛出异常,如果是 unchecked,则 C# 将不定义它的值应该是什么
- decimal 到整数类型,也是一样,但是如果溢出,就会抛出异常,checked 和 unchecked 对其没有影响
- 如果是 double 到 float,也会被舍入,如果太小无法被 float 表示,会被设置为 正 0 或 负 0,如果太大,则会被设置为正无穷大或负无穷大
- float 或 double 到 decimal ,如果太小会被设置为 0,如果太大,那么会抛出异常
- decimal 到 float 或 double 类型总是会成功,但可能会损失精度
引用转换
引用的转换本质上是返回一个新的引用指向原来的位置,但是把引用标记为其他类型
隐式转换
- 所有引用类型都可以被隐式转换为 object 类型
- 任何接口可以被隐式转换为它继承的接口
- 类可以隐式转换为
- 它继承链中的任何类
- 它实现的任何接口
委托可以隐式转换图中所示的 .Net BCL 类和接口。ArrayS 数组(其中的元素是 Ts 类型)可以被隐式转换成
- 图中所示的 .Net BCL 类和接口
- 另一个数组 ArrayT,其中的元素是 Tt 类型(如果满足下面所有条件)
- 两个数组维度一样
- 元素类型 Ts 和 Tt 都是引用类型,不是值类型
- 在类型 Ts 和 Tt 中存在隐式转换
显式引用转换
显式转换包括
- 从 object 到任何引用类型的转换
- 从基类到派生自它的类的转换
这种转换,编译时不会报错,但是一旦在运行时发生了不安全的转换(基类强行转换成派生类)就会抛出异常。
有效显式引用转换
上面提到把基类转换成派生类是会抛出异常的,但是如果是 null,则是允许的,亦或者是基类引用但是实际上指向的是派生类的实例,此时转换回派生类,也是允许的(本质上是基类的实例不能转换成派生类的实例,因为会增加不能访问的字段)
装箱转换
包括值类型在内的所有 C# 类型都派生自 object 类型。然而,值类型是高效轻量的类型,因为默认情况下在堆上不包括它们的对象组件。然而,如果需要对象组件,我们可以使用装箱。装箱是一种隐式转换,它接受值类型的值,根据这个值在堆上创建一个完整的引用类型对象并返回对象引用
需要装箱的一个常见场景是将一个值类型当作参数传递给一个方法,而参数类型是对象的数据类型
装箱是创建副本
装箱时是创建了一个值的引用类型副本,原值和引用类型副本是独立操作的
static void Main()
{
int i = 10;
object oi = i;
Console.WriteLine($"i:{i},io:{oi}");
i = 12;
oi = 15;
Console.WriteLine($"i:{i},io:{oi}");
}
// i:10,io:10
// i:12,io:15
装箱转换
任何值类型 ValueTypeS
都可以隐式转换为 object
、System.ValueType
、InterfaceT
类型(如果 ValueTypeS
实现了InterfaceT
)
拆箱转换
拆箱就是把装箱后的对象转换回值类型的过程
- 拆箱是显式转换
- 系统把值拆箱成
ValueTypeT
时执行了如下的步骤:- 它检测到要拆箱的对象实际是
ValueTypeT
的装箱值 - 它把对象的值复制到变量
- 它检测到要拆箱的对象实际是
static void Main()
{
int i = 10;
object oi = i;
int j = (int)oi;
Console.WriteLine($"i:{i},io:{oi},j:{j}");
}
// i:10,io:10,j:10
用户自定义转换
前面讲 implicit
和 explicit
关键字时提过
- 除了
implicit
和explicit
关键字,隐式转换和显式转换语法是一样的 - 必须
public
和static
public static implicit operator TargetType (SourceType Identifier)
{
...
return ObjectOfTargetType;
}
用户自定义转换的约束
- 只可以为类和结构定义用户自定义转换
- 不能重定义标准隐式或显式转换
- 对于源类型 S 和目标类型 T,如下命题为真
- S 和 T 必须是不同类型
- S 和 T 不能通过继承关联。也就是说,S 不能派生自 T,而 T 也不能派生自 S
- S 和 T 都不能是接口类型或 object 类型
- 转换运算符必须是 S 或 T 的成员
- 对于相同的源类型和目标类型,不能声明两种转换,一个是隐式转换而另一个是显式转换
多步用户自定义转换
类型转换如果都是隐式的话,可以链式的从头转换到尾,一次完成
下面这个示例中 Employee
类到基类 Person
可以隐式转换, Person
到 int 的也实现了自定义的隐式转换,而 int 到 float 也存在标准的隐式转换,因此可以直接将 Employee
的引用转换成 float
class Employee : Person
{
}
class Person
{
public string Name;
public int Age;
public static implicit operator int(Person p)
{
return p.Age;
}
}
class Program
{
static void Main()
{
Employee bill = new Employee();
bill.Name = "William";
bill.Age = 25;
float fVar = bill;
Console.WriteLine($"Person Info: {bill.Name}, {fVar}");
}
}
is 运算符
之前已经说过,有些转换是不成功的,并且会抛出 InvalidCastException
异常。
我们可以使用 is 运算符类来检查转换是否会成功完成,从而避免盲目尝试转换
如果表达式可以通过以下方式成功转换为目标类型,则运算符返回 true:
- 引用转换
- 装箱转换
- 拆箱转换
class Employee : Person
{
}
class Person
{
public string Name = "Anonymous";
public int Age = 25;
}
class Program
{
static void Main()
{
Employee bill = new Employee();
if (bill is Person)
{
Person p = bill;
Console.WriteLine($"Person Info:{p.Name}, {p.Age}");
}
}
}
as 运算符
as 运算符前面提过,和强制类型转换类似,只是不抛出异常,如果转换失败,返回 null,目标类型只能是引用类型
class Employee : Person
{
}
class Person
{
public string Name = "Anonymous";
public int Age = 25;
}
class Program
{
static void Main()
{
Employee bill = new Employee();
Person p = bill as Person;
if (p != null)
{
Console.WriteLine($"Person Info:{p.Name}, {p.Age}");
}
}
}
泛型
什么是泛型
类似 C++ 的模板,可以把类的行为提取出来或重构,使之能应用到其他类型上
C# 中的泛型
泛型允许我们声明类型参数化的代码,用不同的类型进行实例化
C# 提供 5 种泛型:类、结构、接口、委托和方法。注意前面四个是类型,方法是成员,
class MyStack<T>
{
private int StackPointer = 0;
T[] StackArray;
public void Push(T x)
{
...
}
public T Pop()
{
...
}
}
泛型类
声明泛型类
如上面样例所示
- 用法类似 C++ 模板,只不过是在类名后放一组尖括号
- 在尖括号中用逗号分隔的占位字符串来表示需要提供的类型。这叫类型参数
- 在泛型类声明的主体中使用类型参数来表示替代类型
创建构造类型
同 C++ 创建具体的模板类,在创建构造类型时提供的真实类型是类型实参
SomeClass<short,int>
创建变量和实例
同 C++ 创建模板类的对象
class SomeClass<T1, T2>
{
public T1 SomeVar;
public T2 OtherVar;
}
class Program
{
static void Main()
{
SomeClass<short, int> first = new SomeClass<short, int>();
var second = new SomeClass<int, long>();
}
}
类型参数的约束
因为不知道实际的类型,因此也不会知道这些类型实现的成员,除了 object 类的成员,包括 ToString
、Equals
以及 GetType
方法
为了让泛型更有用,我们需要提供额外的信息让编译器知道参数可以接受哪些类型。这些额外的信息叫约束。
Where 子句
约束使用 where 子句,作用上类似 C++ 20 新增的 concept
- 每一个有约束的类型参数都有自己的 where 子句
- 如果形参有多个约束,它们在 where 子句中使用逗号分隔
- 它们在类型参数列表的关闭尖括号之后列出
- 它们不使用逗号或其他符号分隔
- 它们可以以任何次序列出
- where 是上下文关键字,可以在其他上下文中使用
例如,如下泛型类有 3 个类型参数。T1是未绑定的类型参数。对于T2,只有 Customer
类型的类或其派生的类才能作为类型实参。而对于 T3,只有实现 IComparable
接口的类才能作为类型实参
class myClass<T1, T2, T3>
where T2 : Customer
where T3 : IComparable
{
...
}
约束类型和次序
共有五种类型的约束
- 类名:只有这个类及其派生类才能作为类型实参
- class:任何引用类型,包括类、数组、委托和接口都可以作为类型实参
- struct:任何值类型都可以作为类型实参
- 接口名:只有这个接口或实现了这个接口的类型才能作为类型实参
- new():任何带有无参公共构造函数的类型都可以作为类型实参。这叫作构造函数约束
where 语句可以以任何次序列出。然而 where 子句中的约束必须有特定的顺序
- 最多只能有一个主约束,而且必须放第一位(类名、class、struct)
- 可以有任意多的接口名称约束
- 如果存在构造函数约束,必须放最后
泛型方法
类似 C++ 的函数模板,泛型方法可以在泛型和非泛型类以及结构和接口中声明
声明泛型方法
泛型方法具有类型参数列表和可选的约束
- 泛型方法有两个参数列表
- 封闭在圆括号内的方法参数类别
- 封闭在尖括号内的类型参数类别
- 要声明泛型方法,需要
- 在方法名称之后和方法参数列表之前放置类型参数列表
- 在方法参数列表后放置可选的约束子句
public void PrintData<S, T>(S s, T t) where S : class
{
...
}
调用泛型方法
和 C++ 函数模板一样,如果可以直接从方法参数中推断出类型参数,就可以省略类型参数列表,和普通方法一样调用
class Simple
{
static public void ReverseAndPrint<T>(T[] arr)
{
Array.Reverse(arr);
foreach (T item in arr)
Console.Write($"{item}, ");
Console.WriteLine();
}
}
class Program
{
static void Main()
{
var intArray = new int[] { 3, 5, 7, 9, 11 };
var stringArray = new string[] { "first", "second", "third" };
var doubleArray = new double[] { 3.567, 7.891, 2.345 };
Simple.ReverseAndPrint<int>(intArray);
Simple.ReverseAndPrint(intArray);
Simple.ReverseAndPrint<string>(stringArray);
Simple.ReverseAndPrint(stringArray);
Simple.ReverseAndPrint<double>(doubleArray);
Simple.ReverseAndPrint(doubleArray);
}
}
// 11, 9, 7, 5, 3,
// 3, 5, 7, 9, 11,
// third, second, first,
// first, second, third,
// 2.345, 7.891, 3.567,
// 3.567, 7.891, 2.345,
扩展方法和泛型类
扩展方法也可以和泛型类结合使用,和非泛型类一样,泛型类的扩展方法:
- 必须声明为 static
- 必须是静态类的成员
- 第一个参数类型必须有 this 关键字,后面是扩展的泛型类的名字
static class ExtendHolder
{
public static void Print<T>(this Holder<T> h)
{
T[] vals = h.GetValues();
Console.WriteLine($"{vals[0]},\t{vals[1]},\t{vals[2]}");
}
}
class Holder<T>
{
private T[] Vals = new T[3];
public Holder(T v0, T v1, T v2)
{
Vals[0] = v0;
Vals[1] = v1;
Vals[2] = v2;
}
public T[] GetValues()
{
return Vals;
}
}
class Program
{
static void Main()
{
var intHolder = new Holder<int>(3, 5, 7);
var stringHolder = new Holder<string>("a1", "b2", "c3");
intHolder.Print();
stringHolder.Print();
}
}
泛型结构
同泛型类
泛型委托
基本类似泛型方法的声明形式,只不过是换成委托形式而已。C# 的 LINQ 特性大量使用了泛型委托,在介绍 LINQ 之前有必要给出另一个示例
public delegate TR Func<T1, T2, TR>(T1 p1, T2 p2);
class Simple
{
static public string PrintString(int p1, int p2)
{
int total = p1 + p2;
return total.ToString();
}
}
class Program
{
static void Main()
{
var myDel = new Func<int, int, string>(Simple.PrintString);
Console.WriteLine($"Total: {myDel(15, 13)}");
}
}
// Total: 28
泛型接口
基本同泛型类
- 不同类型参数实例化的泛型接口的实例是不同的接口
- 我们可以在非泛型类型中实现泛型接口
泛型接口的实现必须唯一
实现泛型类型接口时,必须保证类型实参的组合不会在类型中产生两个重复的接口,下面这个实例中 S 如果是 int 就会产生冲突
interface IMyIfc<T>
{
T ReturnIt(T inValue);
}
class Simple<S> : IMyIfc<int>, IMyIfc<S> // 编译会报错
{
public int ReturnIt(int inValue)
{
return inValue;
}
public S ReturnIt(S inValue)
{
return inValue;
}
}
泛型接口的名字是不会和非泛型冲突的,因此可以声明一个非泛型的,一个泛型的
协变和逆变
可变性有三种——协变、逆变和不变
协变
同 C++ 的赋值兼容原则,可以把派生类的对象赋值给基类的变量,正常情况下是没问题的,但是当使用泛型委托时,很容易犯下面这种错误
class Animal
{
public int Legs = 4;
}
class Dog : Animal
{
}
delegate T Factory<T>();
class Program
{
static Dog MakeDog()
{
return new Dog();
}
static void Main()
{
Factory<Dog> dogMaker = MakeDog;
Factory<Animal> animalMaker = dogMaker; // 编译错误
}
}
使用派生类构造的委托和使用基类构造的委托,两者本身没有派生关系,因此不适用于赋值兼容原则。但是在实例代码中,只要我们执行 animalMaker
委托,调用代码就希望返回的是一个 Animal 对象的引用,所以如果返回指向 Dog 对象的引用也应该完全可以,因为根据赋值兼容原则,指向 Dog 的引用就是 指向 Animal 的引用,但是由于委托类型不匹配,我们不能进行这种赋值。
仔细分析情况,我们可以看到,如果类型参数只用作输出值,则同样的情况也适用于任何泛型委托。对于这类情况,我们可以使用由派生类创建的委托类型,这样应该能够正常工作,因为调用代码总是期望获得一个基类的引用,这也正是它会得到的
仅将派生类型用作输出值与构造委托有效性之间的常数关系叫作协变。为了让编译器知道这是我们的期望,必须使用 out 关键字标记委托声明中的类型参数,就可以通过编译了
delegate T Factory<out T>();
协变关系允许程度更高的派生类型处于返回及输出位置
逆变
协变处理的是输出的情况,与之相对应的逆变就是处理输入的情况
class Animal
{
public int Legs = 4;
}
class Dog : Animal
{
}
class Program
{
delegate void Action1<in T>(T a);
static void ActOnAnimal(Animal a)
{
Console.WriteLine(a.Legs);
}
static void Main()
{
Action1<Animal> act1 = ActOnAnimal;
Action1<Dog> dog1 = act1;
dog1(new Dog());
}
}
和之前的情况类似,默认情况下不可以赋值两种不兼容的类型,但是如果类型参数只用作方法中的输入参数的话就可以了,因为即使调用代码传入了一个程度更高的派生类引用,委托中的方法也只期望一个程度低一些的派生类的引用。
接口的协变和逆变
同样的原则也适用于接口,使用 in 和 out 关键字处理相关情况
关于可变性的更多内容
还有一种情况下,编译器可以自动识别某个已构建的委托是协变还是逆变并且自动进行类型强制转换。这通常发生在没有为对象的类型赋值的时候
class Animal
{
public int Legs = 4;
}
class Dog : Animal
{
}
delegate T Factory<T>();
class Program
{
static Dog MakeDog()
{
return new Dog();
}
static void Main()
{
Factory<Animal> animalMaker = MakeDog; // 隐式强制转换,不需要 out
}
}
- 可变性处理的是使用基类型替换派生类型的安全情况,反之亦然。因此可变性只适用于引用类型,因为不能从值类型派生其他类型
- 使用 in 和 out 关键字的显式变化只适用于委托和接口,不适用于类、结构和方法
- 不包括 in 和 out 关键字的委托和接口参数是不变的。这些类型参数不能用于协变或逆变