10. 接口

10.1 显式与隐式接口实现的比较

10.1.1 隐式接口

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var man = new Person();
// Person中所有接口实现在man实例中暴露
var outputString = $"{man.Career},{man.Name},{man.NickName},{man.ShamCareer}";
Console.WriteLine(outputString);
}
} internal class Person : IKiller, ILover
{
public string Name { get; set; } = "killer";
public string Career { get; set; } = "killer";
public string NickName { get; set; } = "none";
public string ShamCareer { get; set; } = "doctor";
} internal interface IKiller
{
string Name { get; set; }
string Career { get; set; }
} internal interface ILover
{
string NickName { get; set; }
string ShamCareer { get; set; }
}

10.1.2 显式接口

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var man = new Person();
// Person中IKiller接口实现在man实例中无法直接访问
// 直接访问{man.Career},{man.Name}会出错
var outputString = $"{man.NickName},{man.ShamCareer}";
Console.WriteLine(outputString);
// 通过以下方式在man实例中显式访问IKiller接口实现
((IKiller)man).Name = "killer1";
outputString = $"{((IKiller)man).Name},{((IKiller)man).Career}";
Console.WriteLine(outputString);
}
} internal class Person : IKiller, ILover
{
// IKiller接口显式实现
string IKiller.Name { get; set; } = "killer";
string IKiller.Career { get; set; } = "killer";
public string NickName { get; set; } = "none";
public string ShamCareer { get; set; } = "doctor";
} internal interface IKiller
{
string Name { get; set; }
string Career { get; set; }
} internal interface ILover
{
string NickName { get; set; }
string ShamCareer { get; set; }
}

10.2 接口版本升级

C# 8.0 后允许在接口中为方法提供默认实现,虽然仍然不可以在已经发布的接口中修改或删除现有方法,但可以添加新方法,并通过默认实现,来避免破坏基于该接口开发的现有程序。

注:只能在接口中为方法提供默认实现,该方法可以是静态方法也可以不是静态方法,甚至是能够作为程序入口点的静态Main方法(虽然没有这个必要),非静态的字段、属性等其他成员仍然不能在接口中实现。

注:现在能够在接口中定义任何静态成员,且除了被private和sealed关键词修饰的对象之外默认被public virtual关键词修饰,当然也可以手动更改修饰符。

注:接口中的private protectedprotected internal限定范围与类不一致。其余访问修饰符与类中的访问修饰符限定范围保持一致。

附:类中的访问修饰符限定范围

附:接口中的private protected

  • 使用私有保护访问修饰符声明的接口只能当前接口内部当前程序集内的派生接口内部访问,当前接口的实现类以及实现类的外部均不能访问。

附:接口中的protected internal

  • 使用保护内部修饰符声明的接口只能当前接口内部当前程序集内的派生接口当前程序集内的实现类访问

可访问修饰符总结:如果没有明确需求,接口中所有成员均保持默认的可访问级别(public),不要随意限制接口成员的可访问范围。

注:也可以指定接口中的方法为abstract,但没有任何意义,如果没有为接口中的一个方法指定默认实现,那么该方法就已经是抽象的了。

10.2.1 向接口中添加默认接口方法

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var man = new Person
{
// 直接访问{man.Career},{man.Name}会出错,但可以直接访问下列实例
NickName = "nickName",
ShamCareer = "teacher"
};
// Person中IKiller接口实现在man实例中无法直接访问,也无法通过初始化器在man的构造过程中初始化,必须显式调用IKiller接口
((IKiller)man).Name = "killer1";
((IKiller)man).Career = "hiddenKiller";
// 隐式接口也可以显式调用
((ILover)man).NickName = "nickName1";
((ILover)man).ShamCareer = "engineer";
// C#不允许直接访问接口的默认实现,只能显式访问,在实现类中也是如此
((ILover)man).OutputString();
((IKiller)man).OutputString();
}
} internal class Person : IKiller, ILover
{
// IKiller接口显式实现
string IKiller.Name { get; set; } = "killer";
string IKiller.Career { get; set; } = "killer";
public string NickName { get; set; } = "none";
public string ShamCareer { get; set; } = "doctor";
} internal interface IKiller
{
string Name { get; set; } string Career { get; set; } // C#8.0 新特性 在接口中定义新方法并提供默认实现
// 注:该方法默认为virtual,所以virtual关键词可以省略
// 注:在接口中定义的方法也能指定访问级别,默认为public
internal virtual void OutputString()
{
var outputString = $"{Name},{Career}";
Console.WriteLine(outputString);
}
} internal interface ILover
{
string NickName { get; set; } string ShamCareer { get; set; } // C#8.0 新特性 在接口中定义新方法并提供默认实现
internal void OutputString()
{
var outputString = $"{NickName},{ShamCareer}";
Console.WriteLine(outputString);
}
}

10.2.2 保护访问的接口方法——接口成员修饰符使用示例

依赖虚方法的实现【不安全,Run方法可被覆写,不能保证Start方法和Stop方法一定被正确调用】

public class WorkflowActivity
{
private void Start()
{
//critical code
} public virtual void Run()
{
Start();
// do something...
Stop();
} private void Stop()
{
//critical code
}
}

假设工作流程操作的完整实现要求如下封装:

  1. Run()方法不可被重写;
  2. Start()和Stop()方法不可以被外界调用,它们的调用顺序需要被接口IWorkFlowActivity完全控制;
  3. 上述代码中的do something...部分可以被任意代码替换;
  4. 即使Start()和Stop()方法需要被重写,实现它们的类也未必需要去调用它们,因为何时调用它们属于整个业务逻辑中基础实现的一部分;
  5. 派生类也实现一个Run()方法,但当调用IWorkFlowActivity的Run()方法时,派生类的Run()方法不会被调用

示例如下:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var activity = new ExecuteProcessActivity("dotnet");
Console.WriteLine("Invoking ((IExecuteProcessActivity)activity).Run()...");
((IExecuteProcessActivity)activity).Run();
Console.WriteLine();
Console.WriteLine("Invoke activity.Run()...");
ExecuteProcessActivity.Run();
}
} public interface IWorkflowActivity
{
private static void Start()
{
Console.WriteLine("IWorkflowActivity.Start()...");
// other critical code..
} private static void Stop()
{
Console.WriteLine("IWorkflowActivity.Stop()...");
// other critical code..
} // sealed: can not be overriden by any other class or interface
public sealed void Run()
{
try
{
Start();
InternalRun(); // do something...
}
catch (Exception e)
{
Console.WriteLine(e);
}
finally
{
Stop();
}
} protected void InternalRun();
} // "do something" 在接口IExecuteProcessActivity中完成
// Same as IWorkflowActivity, InternalRun() also has critical code and other code, which were divided into three different methods.
// They are: RedirectStandardInOut() RestoreStandardInOut() and ExecuteProcess()
// Meanwhile ExecuteProcess() is protected abstract which means it can be overriden by derived classes or derived interfaces
// In this interface, we have an explicit interface implementation called IWorkflowActivity.InternalRun() giving a default implementation of InternalRun() in IWorkflowActivity.
public interface IExecuteProcessActivity : IWorkflowActivity
{
protected void RedirectStandardInOut()
{
Console.WriteLine("IExecuteProcessActivity.RedirectStandardInOut()...");
} protected void RestoreStandardInOut()
{
Console.WriteLine("IExecuteProcessActivity.RestoreStandardInOut()...");
} protected void ExecuteProcess(); void IWorkflowActivity.InternalRun()
{
RedirectStandardInOut();
ExecuteProcess();
RestoreStandardInOut();
}
} internal class ExecuteProcessActivity : IExecuteProcessActivity
{
private string ExecutableName { get; } // constructor
public ExecuteProcessActivity(string executablePath)
{
ExecutableName = executablePath ?? throw new ArgumentNullException(nameof(executablePath));
} // override
void IExecuteProcessActivity.RedirectStandardInOut()
{
Console.WriteLine("override: ExecuteProcessActivity.RedirectStandardInOut()...");
} // implement
void IExecuteProcessActivity.ExecuteProcess()
{
Console.WriteLine("implement: ExecuteProcessActivity.IExecuteProcessActivity.ExecuteProcess()...");
} public static void Run()
{
var activity = new ExecuteProcessActivity("dotnet"); // Protected virtual members cannot be invoked by the implementing class even when implemented in the class
//((IWorkflowActivity)this).InternalRun();
//activity.RedirectStandardInOut();
//activity.ExecuteProcess(); Console.WriteLine($@"Executing non-polymorphic Run() with process '{activity.ExecutableName}'.");
}
} /*
运行结果如下:
Invoking ((IExecuteProcessActivity)activity).Run()...
IWorkflowActivity.Start()...
override: ExecuteProcessActivity.RedirectStandardInOut()...
implement: ExecuteProcessActivity.IExecuteProcessActivity.ExecuteProcess()...
IExecuteProcessActivity.RestoreStandardInOut()...
IWorkflowActivity.Stop()... Invoke activity.Run()...
Executing non-polymorphic Run() with process 'dotnet'.
*/

10.3 接口和抽象类的区别


11. 值类型

11.1 隐式装箱拆箱的性能问题及解决方案

11.1.1 示例1——容易忽视的box和unbox指令

using System.Collections;

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var list = new ArrayList();
if (list == null) throw new ArgumentNullException(nameof(list));
Console.WriteLine("Enter a number between 2 and 1000");
var totalCount = int.Parse(Console.ReadLine() ?? throw new InvalidOperationException());
if (totalCount == 7)
{
// int 0 box int => object
list.Add(0);
}
else
{
// double 0 box double=>object
list.Add(0D);
} list.Add((double)0); // same as list.Add(0D);
list.Add((double)1); // same as list.Add(0D);
for (var count = 2; count < totalCount; count++)
{
// unbox list[count - 1] object => double
// unbox list[count - 2] object => double
// box list.Add() double => object
list.Add((double)list[count - 1]! + (double)(list[count - 2]!));
} // unbox.any unbox every element in list object => double
foreach (double count in list)
{
Console.Write($"{count}, ");
}
}
}

使用泛型版本的ArrayList可以避免频繁地装箱拆箱操作,此时所有操作都用已经拆箱的类型完成,就可避免内存分配和类型检查。

11.1.2 示例2——容易忽视的装箱问题

namespace ConsoleApp1;

internal interface IAngle
{
void MoveTo(int degrees, int minutes, int seconds);
} internal struct Angle : IAngle
{
public int Degrees { get; private set; }
public int Minutes { get; private set; }
public int Seconds { get; private set; } public Angle(int degrees, int minutes, int seconds)
{
Degrees = degrees;
Minutes = minutes;
Seconds = seconds;
} // Note: this makes value type struct named Angle mutable which against the general guideline
public void MoveTo(int degrees, int minutes, int seconds)
{
Degrees = degrees;
Minutes = minutes;
Seconds = seconds;
}
} internal static class Program
{
internal static void Main()
{
var angle = new Angle(25, 58, 23);
object objectAngle = angle; // Example1: Simple box operation
// 装箱 => 拆箱并输出箱子中的值 (代码行为符合预期)
Console.WriteLine(
$"{((Angle)objectAngle).Degrees}, {((Angle)objectAngle).Minutes}, {((Angle)objectAngle).Seconds}"); // Example2: Unbox, modify unboxed value then discard the value
// 拆箱 => 修改拆箱后得到的临时变量的值(该修改无效,没有把修改后的值装回原先的箱子) => 再次拆箱并输出箱子中的值(箱子中的值未改变)
((Angle)objectAngle).MoveTo(30, 58, 23);
Console.WriteLine(
$"{((Angle)objectAngle).Degrees}, {((Angle)objectAngle).Minutes}, {((Angle)objectAngle).Seconds}"); // Example3: Box, modify boxed value then discard the value
// 装箱 => 修改箱子中的值(该修改无效) => 输出未装箱的值(虽然成功修改了箱子中的值,但输出的是装箱之前的值,该修改仍然无效)
((IAngle)angle).MoveTo(30, 58, 23);
Console.WriteLine($"{((Angle)angle).Degrees}, {((Angle)angle).Minutes}, {((Angle)angle).Seconds}"); // Example4: Modify boxed value directly
// 装箱 => 修改箱子中的值 => 拆箱并输出箱子中的值 (代码行为符合预期)
((IAngle)objectAngle).MoveTo(30, 58, 23);
Console.WriteLine(
$"{((Angle)objectAngle).Degrees}, {((Angle)objectAngle).Minutes}, {((Angle)objectAngle).Seconds}");
}
}

11.1.3 避免拆箱和拷贝——在已经装箱的值类型上调用接口方法

如11.1.2中Example4所示,在已经装箱的值类型上调用接口方法可以避免拆箱过程,无需考虑箱子中的值(值类型在堆上的副本)与未装箱的值(值类型)同步的问题;如11.1.2中Example2所示,将拆箱后得到的值修改后再装箱,忘了将修改后的值再装回原先的箱子;又如11.1.2中Example3所示,装箱并修改箱子中的值后,没有更新原先的值(值类型)。

下面以调用int值类型实现的IFormattable接口中的ToString()方法为例演示这一避免拆箱和拷贝的技巧:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
const int number = 42;
// boxing
object thing = number; // No unboxing conversion
var text = ((IFormattable)thing).ToString("X", null); Console.WriteLine(text);
}
}

CLR代码如下:

.class private abstract sealed auto ansi beforefieldinit
ConsoleApp1.Program
extends [System.Runtime]System.Object
{ .method assembly hidebysig static void
Main() cil managed
{
.entrypoint
.maxstack 3
.locals init (
[0] object thing,
[1] string text
) // [6 5 - 6 6]
IL_0000: nop // [9 9 - 9 31]
IL_0001: ldc.i4.s 42 // 0x2a
IL_0003: box [System.Runtime]System.Int32
IL_0008: stloc.0 // thing // [13 9 - 13 62]
IL_0009: ldloc.0 // thing
IL_000a: castclass [System.Runtime]System.IFormattable
IL_000f: ldstr "X"
IL_0014: ldnull
IL_0015: callvirt instance string [System.Runtime]System.IFormattable::ToString(string, class [System.Runtime]System.IFormatProvider)
IL_001a: stloc.1 // text // [16 9 - 16 33]
IL_001b: ldloc.1 // text
IL_001c: call void [System.Console]System.Console::WriteLine(string)
IL_0021: nop // [17 5 - 17 6]
IL_0022: ret } // end of method Program::Main
} // end of class ConsoleApp1.Program

11.2 枚举作为标志使用——自定义标志位

设计规范

  1. 要用FlagsAttribute标记包含标志的枚举

  2. 要为所有标志枚举提供等于0的None值

  3. 将标志枚举中的0值定义为“所有标志都未设置”,而不是其他意思

  4. 考虑为常用标志组合提供特殊值

  5. 不要包含哨兵值(如:Maximum)

  6. 要用2的乘方定义标志确保所有标志组合都不重复(建议使用移位操作定义标志)

namespace ConsoleApp1;

[Flags]
internal enum DistributedChannel
{
None = 1 << 0, // 00001
Transacted = 1 << 1, // 00010
Queued = 1 << 2, // 00100
Encrypted = 1 << 3, //01000
Persisted = 1 << 4, //10000 // 为常用标志提供组合值
FaultTolerant = Transacted | Queued | Persisted,
All = Persisted | Encrypted | Queued | Transacted | None
} internal static class Program
{
internal static void Main()
{
var list = new List<DistributedChannel>
{
DistributedChannel.None,
DistributedChannel.Encrypted,
DistributedChannel.FaultTolerant,
DistributedChannel.All
}; foreach (var item in list)
{
Console.WriteLine($"{item}, {Convert.ToString((int)item, 2),+5}");
}
}
}

12. 合式类型

12.1 重写object成员

12.1.1 重写ToString()

设计规范

  1. 如果需要返回有用的、面向开发者的诊断字符串,就要重写ToString()方法

  2. 尽量使ToString()返回的字符串简短

  3. 不要从ToString()返回空字符串来代表“空”

  4. 不要从ToString()抛出异常或造成可观察到的副作用(改变对象状态)

  5. 如果返回值与语言文化相关或要求格式化就要重载ToString()或实现IFormattable接口

  6. 考虑从ToString()返回独一无二的字符串以标识对象实例

示例:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var coordinate = new Coordinate(10.2, 100.3);
Console.WriteLine(coordinate.ToString());
Console.WriteLine($"{coordinate.Longitude}, {coordinate.Latitude}");
}
} internal readonly struct Coordinate
{
public Coordinate(double longitude, double latitude)
{
Longitude = longitude;
Latitude = latitude;
} public double Longitude { get; }
public double Latitude { get; } // override Object.ToString()
public override string ToString()
{
return $"{Longitude}, {Latitude}";
}
}

12.1.2 重写GetHashCode()

设计规范

  1. 重写Equals()之前一定要重写GetHashCode(),否则编译器会显示警告

  2. 将类作为哈希表集合(如:System.Collections.Hashtable和System.Collections.Generic.Dictionary)的键(key)使用也应重写GetHashCode()

  3. 必须保证相等的对象必然有相等的Hash码

  4. 在特定对象的生存期内,即使对象的数据发生了改变,GetHashCode()也应始终返回相同的值。通常将对象的Hash码缓存下来,并让GetHashCode()返回缓存值,从而确保其返回值不变。【不要通过Hash码判断两个对象是否相等,数据相等的两个对象可能有不同的Hash码,数据不相等的两个对象也有可能有相同的Hash码。Hash码的作用是生成和对象对应的数字,与对象内的数据无关。如果两个对象相同就有相同的Hash码,即使两个对象中的数据不同;如果是两个不同对象就有不同的Hash码,即使这两个对象中的数据相同】

  5. GetHashCode()不应引发任何异常,并且总是成功返回一个值

  6. 哈希码应该尽可能唯一

  7. 哈希码应当在取值范围内(int)随机分布

  8. 尽量优化重载后的GetHashCode()性能

  9. 两个对象的细微差异应造成Hash值的极大差异

  10. 攻击者应当难以伪造具有特定Hash值的对象

示例:使用System.HashCode的Combine()方法组合一个对象中一些特征成员的Hash码作为整个对象的Hash码

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var coordinate = new Coordinate(10.2, 100.3);
Console.WriteLine(coordinate.ToString());
Console.WriteLine($"{coordinate.Longitude}, {coordinate.Latitude}");
}
} internal readonly struct Coordinate
{
public Coordinate(double longitude, double latitude)
{
Longitude = longitude;
Latitude = latitude;
} public double Longitude { get; }
public double Latitude { get; } // override Object.ToString()
public override string ToString()
{
return $"{Longitude}, {Latitude}";
} // override Object.GetHashCode()
public override int GetHashCode()
{
return HashCode.Combine(Longitude.GetHashCode(), Latitude.GetHashCode());
}
}

12.1.3 重写Equals()

重写Equals()而不重写GetHashCode()会得到一个警告

warning CS0659: overrides Object.Equals(object? o) but does not override Object.GetHashCode()

值类型永远不会引用相等

值类型永远不会引用相等,即使对同一值类型进行若干次装箱操作也不会引用相等(总是会装在不同的箱子中)

设计规范

  1. 要一起实现GetHashCode()、Equals()、==操作符和!=操作符,缺一不可

  2. 要用相同算法实现Equals()、==和!=

  3. 不要在GetHashCode()、Equals()、==和!=的实现中抛出异常

  4. 避免在可变引用类型上重载相等性操作符(如果重载实现速度过慢也不要重载)

  5. 要在实现IEquitable时实现与相等性相关的所有方法

重写Equals()的步骤

  1. 检查是否为null

  2. 检查数据类型是否相同

  3. 调用一个指定了具体类型的辅助方法,传入参数为具体要比较的类型而不是object

  4. 可能要检查Hash码是否相等来短路一次全面的、逐字段的比较,相等的两个对象不可能Hash码不同

  5. 如果传入对象的obj基类重写了Equals()需要检查base.Equals()

  6. 比较每一个标识字段(关键字段),判断是否相等

  7. 重写GetHashCode()

  8. 重写==和!=操作符

示例:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var instance1 = new Coordinate(longitude: 30, latitude: 50);
var instance2 = new Coordinate(longitude: 30, latitude: 50);
var instance3 = new Coordinate(longitude: 50, latitude: 30); Console.WriteLine(instance1.Equals(instance2));
Console.WriteLine(instance1 == instance2);
Console.WriteLine(instance2 != instance3);
Console.WriteLine(instance1.Equals(instance3));
}
} internal readonly struct Coordinate
{
public Coordinate(double longitude, double latitude)
{
Longitude = longitude;
Latitude = latitude;
} private double Longitude { get; }
private double Latitude { get; } // Override Object.Equals()
public override bool Equals(object? obj)
{
// step1: 检查object是否为空
if (obj is null) return false; // step2: 检查数据类型是否相同 如果当前类或结构体类型封闭(sealed class or sealed struct)这一步可以省略
if (this.GetType() != obj.GetType()) return false; // step3: 调用一个指定了具体类型的辅助方法,传入参数为具体要比较的类型而不是object
return Equals((Coordinate)obj);
} private bool Equals(Coordinate obj)
{
// step4: 检查传入对象的Hash码是否与当前对象的Hash码相等
if (this.GetHashCode() != obj.GetHashCode()) return false; // step5: 如果基类重写了Equals()需要检查当前类的基类和obj的比较结果
if (!base.Equals(obj)) return false; // step6: 比较每一个标识字段(关键字段),判断是否相等
var isEqual = this.Longitude.Equals(obj.Longitude) && this.Latitude.Equals(obj.Latitude);
return isEqual;
} // step7: 重载Object.GetHashCode()
public override int GetHashCode()
{
return HashCode.Combine(Longitude.GetHashCode(), Latitude.GetHashCode());
} // step8: 重载 == 和 != 操作符
// 注意:在重载操作符的函数体内不能使用正在重载的操作符,否则会死循环,直到栈溢出才会停止
public static bool operator ==(Coordinate? leftHandSide, Coordinate? rightHandSide)
{
if (leftHandSide is null) return rightHandSide is null;
return leftHandSide.Equals(rightHandSide);
} public static bool operator !=(Coordinate? leftHandSide, Coordinate? rightHandSide)
{
return !(leftHandSide == rightHandSide);
}
}

12.2 垃圾回收

注:该部分详见CLR Via CSharp

12.2.1 .NET中的垃圾回收

.NET的垃圾回收器采用mark-and-compact算法,即:先确定所有可达对象,然后移动这些对象,使它们紧挨着存放,过程类似于磁盘整理。一次垃圾回收周期开始,它识别对象的所有根引用。根引用是来自静态变量、CPU寄存器以及局部变量、参数实例或f-reachable对象的任何引用。基于该猎捕i奥,垃圾回收器可遍历每个根引用所标识的树形结构,并递归确定所有根引用指向的对象。这样,垃圾回收器就可识别出所有可达对象

执行垃圾回收时,垃圾回收器将所有可达对象紧挨着放到一起,从而覆盖不可访问的对象所占用空间,完成垃圾回收操作。不过,如果移动可达对象的开销太大,垃圾回收器会选择释放不可访问对象所占的空间而不会移动该对象。

为定位和移动所有可达对象,系统要在垃圾回收器运行期间维持状态的一致性。为此,进程中的所有托管线程都会在垃圾回收期间暂停。除非某次垃圾回收耗时特别长或者垃圾回收过于频繁,否则这个停顿是可以忽略的。为尽量避免在不恰当的时间执行垃圾回收操作,可以在执行关键代码前显式调用System.GC.Collect()方法。这样做不会阻止垃圾回收器在不恰当的时机运行,但会显著减小它运行的可能性,前提是关键代码执行期间不会发生内存被大量消耗的情况。

.NET垃圾回收器支持“代”(generation)的概念,它会以更快的频率清理生存时间较短的对象(第0代),而那些在一次垃圾回收中存活下来的对象(第1代)会以较低的频率清除,那些在至少两次垃圾回收中存活的对象(第2代)的清除频率还会更低。

12.2.2 弱引用

迄今为止讨论的所有引用都是强引用,强引用维持着对象的可访问性,阻止垃圾回收器清除对象所占用的内存。而弱引用不会阻止对引用对象的垃圾回收操作,这样在对象被垃圾回收之前可以被重用。

弱引用是为创建起来代价较高、维护开销很大的引用对象而设计的。例如,一个很大的对象列表要从一个数据库中加载并向用户显示。在这种情况下,加载列表的代价很高,一旦用户关闭该列表,就应该可以进行垃圾回收。如果用户多次请求这个列表,那么每次都要执行代价高昂的加载动作。解决办法是使用弱引用,这样就可以使用代码检查列表是否被清除,如果未被清除就重新引用同一个列表,如果被清除就需要重新创建。

如果认为引用对象应该是弱引用,就把它定义为System.WeakReference<T>,示例如下:

public static class ByteArrayDataSource
{
private static byte[] LoadData()
{
var data = new byte[1000];
return data;
} private static WeakReference<byte[]>? Data { get; set; } public static byte[] GetData()
{
byte[]? target;
if (Data is null)
{
target = LoadData();
Data = new WeakReference<byte[]>(target);
return target;
} if (Data.TryGetTarget(out target))
{
return target;
} target = LoadData();
Data.SetTarget(target);
return target;
}
}

12.3 资源清理

12.3.1 终结器(Finalizer)

终结器(Finalizer)的声明与C++中的析构器在语法层面上完全一致,但C#终结器不能显式调用,没有和new对应的操作符(C++中可以用delete调用析构器)。在C#中,垃圾回收器负责为对象实例调用终结器,在.NET Core环境中甚至在正常情况下,终结器在程序结束前也有可能不会调用(资源充足,垃圾回收器没有必要暂停全部线程回收资源)。

  • 与C++析构器相似,C#终结器不允许传递任何参数且不可重载,基类中的终结器作为对象终结调用的一部分被自动调用。
  • 与C++析构器不同,C#终结器不能显式调用,能够调用终结器的只能是垃圾回收器。此外,为终结器添加可访问修饰符没有意义,也不允许为终结器添加可访问修饰符。由于垃圾回收器负责所有内存的管理,所以终结器不负责回收内存。相反,它们负责处理像数据库连接和文件句柄(handler)这样的资源释放,尤其是非托管资源,而垃圾回收器不知道怎么处理这些资源。

注意事项

在终结器中避免异常:终结器在自己的线程中执行,这使得它们的执行具有不确定性。这种不确定性使终结器中未处理的异常变得难以诊断。从用户角度看,未处理异常的引发时机相当随机,跟用户当时执行的任何操作都没太大关系。所以一定要在终结器中避免异常。避免异常是采用防卫性编程技术捕获并处理所有可能的异常,不让异常溜出终结器。

在终结器中捕获所有可能的异常:通常应当在终结器中捕获并处理所有可能发生的异常,并通过其他途径汇报,而不是放任这些异常逃逸,留给系统默认处理。好的做法是,将异常捕获并记录在日志文件或显示在用户界面的诊断信息区。

public class TemporaryFileStream
{
public FileStream? Stream { get; private set; }
public FileInfo? FileInfo { get; private set; } public TemporaryFileStream(string filename)
{
FileInfo = new FileInfo(filename);
Stream = new FileStream(FileInfo.FullName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
} public TemporaryFileStream() : this(Path.GetTempFileName())
{
} private void Close()
{
Stream?.Dispose();
try
{
FileInfo?.Delete();
}
catch (IOException exception)
{
Console.WriteLine(exception.Message);
// do something to deal with this kind of exception.
} Stream = null;
FileInfo = null;
} // Finalizer
~TemporaryFileStream()
{
try
{
Close();
}
catch (Exception exception)
{
// Write event to logs or UI.
}
}
}

12.3.2 使用using语句进行确定性终结

终结器的问题在于不支持确定性终结,开发者不能显式指定终结器在何时运行,很有必要提供进行确定性终结的方法来避免依赖终结器不确定的计时行为,以及时释放稀缺资源。由于确定性终结的重要性,基类库为这个模式提供了一个叫做IDisposable的特殊接口,IDisposable接口用名为Dispose()的方法定义了该模式的实现细节,开发者可针对资源类调用该方法,从而释放当前占用的资源。可对12.3.1中的示例做如下改进:

public class TemporaryFileStream : IDisposable
{
public FileStream? Stream { get; private set; }
public FileInfo? FileInfo { get; private set; } public TemporaryFileStream(string filename)
{
FileInfo = new FileInfo(filename);
Stream = new FileStream(FileInfo.FullName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
} // 无参构造函数调用有参构造函数并传入默认参数Path.GetTempFileName()
public TemporaryFileStream() : this(Path.GetTempFileName())
{
} // Finalizer
~TemporaryFileStream()
{
// 表示本次调用是隐式调用,由终结器调用,托管资源释放由GC自动完成,非托管资源释放由终结器来完成
Dispose(false);
} // 释放非托管资源
private void ReleaseUnmanagedResources()
{
Stream?.Dispose();
try
{
FileInfo?.Delete();
}
catch (IOException exception)
{
Console.WriteLine(exception.Message);
// do something to deal with this kind of exception.
} Stream = null;
FileInfo = null;
} // 释放托管资源
private void ReleaseManagedResources()
{
// 在此处释放托管资源...
} // _isDisposed 默认值为false
private bool _isDisposed = false; // 具体执行Dispose操作的私有方法
private void Dispose(bool disposing)
{
ReleaseUnmanagedResources();
// 如果是显式调用的话,就要手动释放托管资源
if (disposing) ReleaseManagedResources();
} public void Dispose()
{
// 如果Dispose方法已经被调用过,则不会再次调用Dispose
if (_isDisposed) return;
// 显式调用private void Dispose(bool disposing);
Dispose(true); // unregister from the finalization queue.
// 将当前类从终结队列(finalization queue)移除,其目的是:
// 告诉CLR,资源已经手动释放,不要再让GC触发当前类的终结器自动回收资源了
GC.SuppressFinalize(this);
_isDisposed = true;
}
}

此处仍然存在一个问题:在TemporaryFileStream实例化之后,在TemporTemporaryFileStream.Dispose()被调用之前,如果程序运行出现异常,则会导致Dispose()方法得不到调用,资源清理不得不依赖终结器,无论出现什么异常,稀缺资源必须及时释放。为避免这一问题,开发者需要实现try/finally代码块,确保出现异常时仍能够执行Dispose方法。C#使用了using语句(语法糖)以简化代码,示例如下:

internal static class Program
{
internal static void Main()
{
using (var fileStream = new TemporaryFileStream())
{
// do something...
}
// release TemporaryFileStream.
}
}

在C#8.0及以后,也可以使用全局using语句,同样可以在超出作用范围后自动释放资源,示例如下:

internal static class Program
{
internal static void Main()
{
using var fileStream = new TemporaryFileStream();
// do something...
}
// release TemporaryFileStream.
}

12.3.3 终结器(Finalizer)的底层运行机制

12.3.3.1 对托管资源和非托管资源的理解

托管资源

由GC自动进行回收的资源,这些资源通常在托管堆上(栈上的资源回收不需要GC负责)

非托管资源

C#的代码不可能不与外界打交道,如:操作系统、数据库等,但GC不知道该如何回收这些资源,这些外界资源就是非托管资源,以StreamWriter举例:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var sw = new StreamWriter("xxx.txt");
sw.WriteLine(".....");
sw.Dispose();
}
}

这里能够写入文件是因为C#代码请求了windows底层的Win32 Api,在这个场景下就有了第三者的介入。sw是引用类型,受CLR管理,Win32 Api是和.NET没有任何关系的外部资源(非托管资源)。如果在用完sw之后没有释放非托管资源,当某个时刻sw被GC回收后,这时被打开的Win32 Api文件句柄(handler)再也无法释放,造成资源泄露。

12.3.3.2 令人头疼的非托管资源

如上所述,如果开发者忘记手动回收非托管资源就会造成资源泄露,如果可以在GC自动回收托管资源的同时回调一个用来回收非托管资源的自定义方法,那么就不用担心资源泄露的问题,这一方法就是终结器。对12.3.3.1的代码做如下改进:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main()
{
var sw = new MyStreamWriter("xxx.txt");
sw.WriteLine("....."); // 忘了调用Dispose()释放非托管资源
//sw.Dispose(); // 强制执行一次GC回收过程
GC.Collect();
GC.WaitForPendingFinalizers(); Console.ReadLine();
}
} internal class MyStreamWriter : StreamWriter
{
public MyStreamWriter(string fileName) : base(fileName)
{
} ~MyStreamWriter()
{
Console.WriteLine("忘记Dispose了吧?");
// 隐式Dispose,资源交给GC回收
base.Dispose(false);
Console.WriteLine("非托管资源自动回收完成了,不要担心.");
}
}

12.3.3.3 终结器被执行的底层原理

CLR在启动时会构建一个"Finalize全局队列"和一个"Finalize待处理队列",所有定义了终结器的类,它们的引用地址均保留一份到"Finalize全局队列"。CLR还会启动一个专门的"Finalize线程",这个线程全权监视"Finalize待处理队列"。GC在开启清理前标记对象的引用,如果发现某一对象A只在"Finalize全局队列"中有引用,说明此对象是待回收的垃圾,CLR就会将该对象转移到"Finalize待处理队列"。不够聪明的GC认为该对象仍然存在引用,此次GC会放过A这一已经是垃圾的对象,随后"Finalize线程"监视到"Finalize待处理队列"有新增对象,于是取出该对象并执行该对象的Finalize方法。由于对象A被破坏性取出队列,此时对象A再无任何引用,在下次GC启动时对象A就会被清理出去。

12.3.3.4 终结器与Dispose方法的关系

如12.3.2所述,终结器的问题在于不支持确定性终结,开发者不能显式指定终结器在何时运行,基类库中的IDisposable接口用名为Dispose()的方法定义了确定性终结这一模式的实现细节。开发者可以使用Dispose()接口显式释放托管资源和非托管资源,此时的终结器成为备用的资源释放方法,这就要求:在开发者使用Dispose方法显式释放资源后,终结器不得再次释放资源。

如12.3.3.3所述,CLR、GC、终结器使用"Finalize全局队列"和"Finalize待处理队列"管理资源,当对象不在"Finalize待处理队列"中时,终结器不会工作,这就阻止了重复资源释放。

如12.3.1所述,C#终结器不能显式调用,能够调用终结器的只能是垃圾回收器。同样,能够操作"Finalize全局队列"的也只有垃圾回收器。如果在手动执行Dispose方法后再手动将该对象的引用从"Finalize全局队列"中移除,该对象就不会进入"Finalize待处理队列",终结器也不会被调用,已经释放的资源也不会再次被终结器释放。为此,基类库提供了System.GC.SuppressFinalize(this)这一方法,该方法可以将当前对象的引用从 "Finalize全局队列" 移除。将该方法添加到Dispose方法中就能达到预期效果了。

12.3.4 在程序结束前强制释放资源——设置程序退出事件处理器

从.NET Core开始,程序结束时终结器不一定会被调用,若要提高终结器被调用的可能性,需要为相应代码进行注册。示例如下:

namespace ConsoleApp1;

internal static class Program
{
internal static void Main(string[] args)
{
Console.WriteLine("Main: Starting...");
DoStuff();
if (args.Any(arg => arg.ToLower() == "-gc"))
{
// 强制垃圾回收
GC.Collect();
GC.WaitForPendingFinalizers();
} Console.WriteLine("Main: Exiting...");
} private static void DoStuff()
{
Console.WriteLine("DoStuff: Starting...");
SampleUnmanagedResource? sampleUnmanagedResource = null;
try
{
sampleUnmanagedResource = new SampleUnmanagedResource();
Console.WriteLine("DoStuff: Use Unmanaged resource..");
}
finally
{
if (Environment.GetCommandLineArgs().Any(arg => arg.ToLower() == "-dispose"))
{
sampleUnmanagedResource?.Dispose();
}
} Console.WriteLine("DoStuff Exiting...");
}
} internal class SampleUnmanagedResource : IDisposable
{
private EventHandler ProcessExitHandler { get; } public SampleUnmanagedResource(string filename)
{
Console.WriteLine($"{nameof(SampleUnmanagedResource)}.ctor: Starting...");
Console.WriteLine($"{nameof(SampleUnmanagedResource)}.ctor: Create managed stuff..."); // 要用弱引用,强引用无法清理自身所用的资源
var weakReferenceToSelf = new WeakReference<IDisposable>(this);
ProcessExitHandler = (_, _) =>
{
Console.WriteLine("ProcessExitHandler: Starting...");
if (weakReferenceToSelf.TryGetTarget(out var self))
{
self.Dispose();
} Console.WriteLine("ProcessExitHandler: Exiting...");
};
// 注册AppDomain.CurrentDomain.ProcessExit事件
AppDomain.CurrentDomain.ProcessExit += ProcessExitHandler;
Console.WriteLine($"{nameof(SampleUnmanagedResource)}.ctor: Exiting...");
} public SampleUnmanagedResource() : this(Path.GetTempFileName())
{
} ~SampleUnmanagedResource()
{
Console.WriteLine("Finalizer Starting...");
Dispose(false);
Console.WriteLine("Finalizer Exiting...");
} private bool _isDisposed = false; public void Dispose()
{
if (_isDisposed) return;
Console.WriteLine("Dispose: Starting...");
Dispose(true);
GC.SuppressFinalize(this);
_isDisposed = true;
Console.WriteLine("Dispose: Exiting...");
} private void ReleaseUnmanagedResources()
{
// release unmanaged resources here
Console.WriteLine("Dispose: Disposing unmanaged stuff...");
} private void ReleaseManagedResources()
{
// release managed resources here
Console.WriteLine("Dispose: Disposing managed stuff...");
} private void Dispose(bool disposing)
{
if (disposing)
{
ReleaseManagedResources();
} ReleaseUnmanagedResources(); // 解除AppDomain.CurrentDomain.ProcessExit事件
AppDomain.CurrentDomain.ProcessExit -= ProcessExitHandler;
}
}

12.3.5 GC、Finalize和IDisposable设计规范

  1. 要只为使用了稀缺或昂贵资源的对象实现终结器方法,即使终结会推迟垃圾回收

  2. 要为有终结器的类实现IDisposable接口以支持确定性终结

  3. 只有当类包含必须释放的资源,而该资源自己又没有终结器时,才要在类中实现终结器

  4. 不要让异常溜出终结器

  5. 若要提高终结器在程序结束前被调用的可能性,考虑将终结器中的代码实现在事件处理器里并注册到AppDomain.CurrentDomain.ProcessExitevent中

  6. 如果一个类同时注册了AppDomain.CurrentDomain.ProcessExitevent又实现了Dispose(),一定要在Dispose()中解除注册的事件。

  7. 要从Dispose()中调用System.GC.SuppressFinalize(),以使垃圾回收更快地发生,并避免重复性的资源清理。

  8. 要保证Dispose()方法被多次调用而不引发异常

  9. 保持Dispose()方法的简单性,把重点放在资源清理上

  10. 尽量依赖终结队列(finalization queue)清理实例,除非有稀缺资源或非托管资源等待释放

  11. 避免在终结方法中引用未被终结的其他对象(不要错误终结不该终结的对象)

  12. 如果基类存在Dispose(),在重写基类的Dispose()时需要调用基类的Dispose()

  13. 在调用Dispose后将对象状态设置为不可用。对象被Dispose后,调用出Dispose()之外的方法应引发ObjectDisposed异常。

  14. 要为含有可Dispose字段或属性的类型实现IDisposable接口,并dispose这些字段或属性引用的对象

  15. 要在派生类的Dispose()中调用基类的Dispose()

12.4 推迟初始化

使用推迟初始化可在需要时才创建或获取对象,而不是提前创建好,某一些对象可能几乎不需要甚至永远都不需要。C#基本库提供了System.Lazy<T>类型来创建需要推迟初始化的对象。该类型本身是线程安全的,会确保在多线程环境下只创建一个实例,但这种线程安全不会保护延迟初始化的对象,如果多个线程可以访问延迟初始化的对象,则必须使延迟初始化的对象中的属性和方法能够安全地进行多线程操作,示例如下:

namespace ConsoleApp1;

internal static class Program
{
private static Lazy<LargeObject>? _lazyLargeObject; private static LargeObject InitLargeObject()
{
var large = new LargeObject(Environment.CurrentManagedThreadId);
return large;
} private static void Main()
{
_lazyLargeObject = new Lazy<LargeObject>(InitLargeObject);
Console.WriteLine(
"\r\nLargeObject is not created until you access the Value property of the lazy" +
"\r\ninitializer. Press Enter to create LargeObject.");
Console.ReadLine(); // Create and start 3 threads, each of which uses LargeObject.
var threads = new Thread[3];
for (var i = 0; i < threads.Length; i++)
{
threads[i] = new Thread(ThreadProc);
threads[i].Start();
} // wait for all 3 threads to finish.
foreach (var thread in threads)
{
thread.Join();
} Console.WriteLine("\r\nPress Enter to end the program");
Console.ReadLine();
} private static void ThreadProc()
{
var large = _lazyLargeObject?.Value;
// IMPORTANT: Lazy initialization is thread-safe, but it doesn't protect the
// object after creation. You must lock the object before accessing it,
// unless the type is thread safe. (LargeObject is not thread safe.)
if (large == null) return;
lock (large)
{
large.Data[0] = Environment.CurrentManagedThreadId;
Console.WriteLine($"Initialized by thread {large.InitializedBy}; last used by thread {large.Data[0]}");
}
}
} internal class LargeObject
{
public int InitializedBy { get; } public LargeObject(int initializedBy)
{
InitializedBy = initializedBy;
Console.WriteLine($"LargeObject was created on thread id {InitializedBy}.");
} public readonly long[] Data = new long[100000000];
}

13. 异常处理

  1. 要在向成员传递了错误参数时抛出ArgumentException或者他的某个子类型。抛出尽可能具体的异常,如:ArgumentNullException

  2. 不要抛出System.SystemException或者它的派生类型

  3. 不要抛出System.Exception、System.NullReferenceException或者System.ApplicationException

  4. 考虑在程序继续执行会变得不安全时调用System.Enviroment.FailFast()来终止进程

  5. 要为传给参数异常类型的paramName实参使用nameof操作符。接收这种实参的异常类型包括ArgumentException、ArgumentOutOfRangeException和ArgumentNullException

13.1 异常处理规范

  1. 避免在调用栈较低的位置报告或记录异常

  2. 不该捕捉的异常不要捕捉。要允许异常在调用栈中向上传播,除非能通过程序准确处理栈中较低位置的错误

  3. 如理解特定异常在给定上下文中发生的原因,并能通过程序处理错误,就考虑捕获该异常。

  4. 避免捕捉System.Exception或System.SystemException,除非是在顶层异常处理程序中先执行最终的清理操作再重新抛出异常。

  5. 要在catch块中使用throw;而不是throw<异常对象>语句,后者会造成异常追踪链断裂

  6. 要先想好异常条件,避免在catch块中重新抛出异常

  7. 重新抛出不同异常时要当心,只有在“更改异常类型可以更好地澄清问题”、“私有数据是原始异常的一部分”、“异常过于具体,以至于调用者不能恰当地处理”这三种情况下才重新抛出异常

  8. 避免在异常条件表达式中抛出异常

  9. 避免以后可能发生变化的异常条件表达式

13.2 自定义异常

namespace ConsoleApp1;

internal static class Program
{
private static void Main()
{
// ...
{
// ...
throw new DatabaseException();
}
// ...
}
} internal class DatabaseException : Exception
{
public DatabaseException(string? message, Exception? exception) : base(message: message,
innerException: exception)
{
// ...
} public DatabaseException() : this("", new Exception())
{
// ...
}
}

设计规范

  1. 避免异常继承层次结构过深

  2. 如果异常不以有别于现有CLR异常的方式处理,就不要创建新异常。相反,应抛出现有框架的异常

  3. 要创建新异常类型来描述特别的程序错误,这种错误无法用现有的CLR异常来描述

  4. 要为所有自定义异常类型提供无参构造函数。还要提供获取消息和内部异常的构造函数。

  5. 要为异常类的名称附加"Exception"后缀

  6. 要使异常能由"运行时"序列化

  7. 考虑提供异常属性,以便通过程序访问关于异常的额外信息

13.3 可序列化异常

可序列化对象是“运行时”可持久化成一个流,然后根据这个流来重新实例化的对象。某些分布式通信技术可能要求异常使用该技术。为支持序列化,异常声明应包含System.SerializableAttribute特性或实现ISerializable接口。此外必须包含一个构造函数来获取System.Runtime.Serialization.SerializationInfo和System.Runtime.Serialization.StreamingContext,示例如下:

using System.Runtime.Serialization;

namespace ConsoleApp1;

// Supporting serialization via an attribute
[Serializable]
internal class DatabaseException : Exception
{
// ... // Used for deserialization of exception
public DatabaseException(SerializationInfo serializationInfo, StreamingContext content) : base(serializationInfo, content)
{
// ...
}
} internal static class Program
{
private static void Main()
{
// ...
}
}

System.SerializableAttribute特性在.NET Standard 2.0及以后才可用,如代码要实现跨框架编译(低于2.0的某一个版本),考虑将自己的System.SerializableAttribute定义成一个polyfill,用于在旧版本中提供原生API支持或在新版本中提供旧版本的原生API支持,如果环境不支持System.SerializableAttribute就加载一个polyfill,这样在两种环境下都可使用这个API。同样也可以使用shim技术,shim将新API引入旧环境,但只使用旧环境已有的技术(非原生API),也可以将旧API引入新环境,但只使用新环境已有的技术,这样可以保证在两种环境下都可使用这个API。

13.4 重新抛出包装的异常

有时,栈中较低位置抛出的异常在高处捕捉时已没有意义,如:假定服务器上磁盘空间耗尽而抛出System.IO.IOException。客户端捕捉到该异常,却理解不了为什么有IO活动。类似地,假定地理坐标请求API抛出System.UnauthorizedAccessException,调用者理解不了API调用和安全性有什么关系。所以不应该向客户端公开这些异常,而是捕获异常,并抛出一个不同的异常,从而告诉用户系统处于无效状态。这种情况下一定要设置“包装异常”(wrapping exception)的InnerException属性。这样就有一个额外的上下文供所调用框架的人进行诊断。包装并重新抛出异常后,原始栈跟踪将被替换为新的栈跟踪。幸好,将原始异常嵌入包装异常原始栈跟踪依然可用。示例如下:

namespace ConsoleApp1;

internal class DatabaseException : Exception
{
public DatabaseException(string message, Exception? exception = null) : base(message, exception)
{
} private const string DefaultExceptionMessage = "DatabaseException"; public DatabaseException(Exception exception) : this(DefaultExceptionMessage, exception)
{
}
} internal static class Program
{
private static void Main()
{
var test = true;
try
{
if (test)
{
throw new DatabaseException(new ArgumentException("ArgumentException"));
}
}
catch (DatabaseException ex)
{
Console.WriteLine(ex.GetType());
Console.WriteLine(ex.InnerException?.GetType());
}
}
}

在上述示例中,使用自定义异常ConsoleApp1.DatabaseException包装了System.ArgumentException

title: 设计规范

1. 如果低层抛出的特定异常在高层运行的上下文中没有意义,考虑将低层异常包装到更恰当的异常中。

2. 要在包装异常时设置内部异常属性

3. 要将开发者作为异常的接收者,尽量说明问题和解决问题的办法

14. 泛型

14.1 泛型类型约束——将类型检查从“运行时”提前到“编译时”

14.1.1 接口约束

using System.Text;

namespace ConsoleApp1;

internal static class Program
{
private static void Main(string[] args)
{
// int实现了IComparable<int>接口,string实现了IComparable<string>接口,均满足约束条件
var intPair = new Pair<int>(1, 2);
var stringPair = new Pair<string>("3", "2");
_ = new PairElementCompare<int>(intPair);
_ = new PairElementCompare<string>(stringPair); // StringBuilder类型没有实现IComparable<StringBuilder>接口,不满足约束条件
// Pair<T>类对泛型T无约束
var stringBuilder = new Pair<StringBuilder>(new StringBuilder().Append("33"), new StringBuilder().Append("23"));
Console.WriteLine(stringBuilder.ToString());
// 下面语句在“编译时”出错,如果IDE支持intellisense,该语句在编译前就能被intellisense感知并提示
// _ = new PairElementCompare<StringBuilder>(stringBuilder);
}
} // 类CustomBinaryTree中的泛型类型T必须实现IComparable<T>接口
internal class PairElementCompare<T> where T : IComparable<T>
{
public PairElementCompare(Pair<T> item)
{
_item = item;
Compare();
} private readonly Pair<T> _item; private void Compare()
{
switch (_item)
{
// 属性匹配, { }为非空匹配,如果First.Item和Second.Item均不为空,则匹配成功
case { First: { } first, Second: { } second }:
switch (first.CompareTo(second))
{
case < 0:
Console.WriteLine("first is less than second.");
break;
case > 0:
Console.WriteLine("second is less than first.");
break;
default:
Console.WriteLine("first is equal to second.");
break;
} break;
default:
throw new InvalidCastException(
$@"Unable to sort the items. {typeof(T)} does not support IComparable<T>.");
}
}
} // 无类型约束的Pair,两个成员类型相同
public sealed class Pair<T>
{
public T First { get; }
public T Second { get; } public Pair(T first, T second)
{
First = first;
Second = second;
} public override string ToString()
{
return $"{First}, {Second}";
}
}

14.1.2 类类型约束——类型参数约束

public class EntityDictionary<TKey, TValue> : Dictionary<TKey, TValue>
where TKey : notnull
where TValue : EntityBase
{
//...
} public abstract class EntityBase
{
}

类类型约束的语法和接口约束基本相同,但如果同时指定多个约束,那么类类型约束必须第一个出现。和接口约束不同,类类型约束不能出现多个(类是单继承的,一个类不可能从多个不相关的类派生)。同样,类类型约束不能指定密封类或者不是类的类型。从C#7.3开始,允许使用System.Enum作为约束来确保类型参数为一个枚举型。但是不能用System.Array将类型参数约束为一个集合。

14.1.3 委托约束——类型参数约束

C#7.3 允许使用System.Delegate和System.MulticastDelegate两个类,将类型参数约束为委托类型。有了这个约束就可以安全地将多个委托对象进行组合(使用静态的Combine方法)和分离(使用静态的Remove方法)。虽然泛型类型无法以强类型的方式调用委托,但还是可以通过DynamicInvoke方法来调用委托(该方法使用反射实现委托调用)。除了使用DynamicInvoke方法还可以在编译时通过对泛型类型T的直接引用调用Combine方法然后强制转换为所需类型,示例如下:

internal static object? InvokeAll<TDelegate>(object?[]? args, params Delegate?[]? delegates)
where TDelegate : MulticastDelegate
{
switch (Delegate.Combine(delegates))
{
case Action action:
action();
return null;
case TDelegate result:
// 调用委托
return result.DynamicInvoke(args);
default:
return null;
}
}

14.1.4 非托管约束

在C#7.3中,可以将类型参数约束为非托管类型,包括:sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool、枚举、指针或者只包含非托管字段的结构体。有了这个约束就可以在代码中对泛型类型T使用sizeof操作符或执行stackalloc操作了。

14.1.5 非空约束与struct/class约束

使用关键字notnull可声明非空约束,notnull不能与sturct或class约束同时使用,因为sturct约束和class约束默认不为空。其中struct将泛型类型约束为值类型,class将泛型类型约束为引用类型(类、接口、委托...)。

14.1.6 构造函数约束

有时要在泛型类中创建类型参数的实例,但并非所有泛型类型都有公共默认构造函数,所以编译器不允许为未约束的泛型类型调用默认构造函数。如果要为泛型类型调用默认构造函数,要在指定了所有约束之后添加new()。示例如下:

internal abstract class EntityBase<TKey> where TKey : notnull
{
// 如果未指定默认无参构造函数,编译器会使用Object的默认无参构造函数作为当前类的默认无参构造函数
// 这个有参构造函数从未被调用
protected EntityBase(TKey key)
{
Key = key;
} public TKey Key { get; init; }
} internal class EntityDictionary<TKey, TValue> : Dictionary<TKey, TValue>
where TKey : IComparable<TKey>, IFormattable
where TValue : EntityBase<TKey>, new() // 要求TValue实现EntityBase类且有默认构造函数
{
// ...
public TValue MakeValue(TKey key)
{
// TValue为EntityBase<Tkey>本身或派生自EntityBase<Tkey>的其他类
// 注意:此处先调用了EntityBase::.ctor构造了一个EntityBase实例newEntity,
// 此时属性Key仍然未被赋值,随后再调用newEntity实例中Key属性的set方法,修改了Key属性的值
var newEntity = new TValue
{
Key = key
};
base.Add(newEntity.Key, newEntity); //继承自Dictionary的Add方法
return newEntity;
}
}

注意:只能为默认构造函数指定约束,不能为有参构造函数指定约束。如果被实例化的类的构造函数必须接收参数,可以将类型参数约束为一个工厂接口,然后由工厂接口的实现类来实例化泛型类(多了层包装,实际上与上一个示例保持一致)。示例如下:

internal abstract class EntityBase<TKey> where TKey : notnull
{
// 如果未指定默认无参构造函数,编译器会使用Object的默认无参构造函数作为当前类的默认无参构造函数
// 这个有参构造函数从未被调用
protected EntityBase(TKey key)
{
Key = key;
} public TKey Key { get; init; }
} // 引入工厂接口,在工厂接口中实例化TValue
internal class EntityDictionary<TKey, TValue, TFactory> : Dictionary<TKey, TValue>
where TKey : IComparable<TKey>, IFormattable
where TValue : EntityBase<TKey> // 引入工厂接口后不再要求TValue具有默认无参构造函数
where TFactory : IEntityFactory<TKey, TValue>, new() // 要求IEntityFactory<T1,T2>的实现类有默认无参构造函数
{
public TValue New(TKey key)
{
// 通过工厂接口为泛型类型TValue创建实例
var newEntity = new TFactory().CreateNew(key);
base.Add(newEntity.Key, newEntity);
return newEntity;
}
} // 工厂接口
internal interface IEntityFactory<in TKey, out TValue>
// 在工厂接口中,泛型的限定条件应与引入工厂接口的类保持一致
where TKey : IComparable<TKey>, IFormattable
where TValue : EntityBase<TKey>
{
TValue CreateNew(TKey key);
} internal class EntityFactory<TKey, TValue> : IEntityFactory<TKey, TValue>
// 在工厂接口的实现类中,泛型的限定条件应与工厂接口保持一致
// 由于需要实例化TValue,所以TValue需要提供默认无参构造函数
where TKey : IComparable<TKey>, IFormattable
where TValue : EntityBase<TKey>, new()
{
// 如果未指定默认无参构造函数,编译器会使用Object的默认无参构造函数作为当前类的默认无参构造函数 public TValue CreateNew(TKey key)
{
// 注意:此处先调用了EntityBase::.ctor构造了一个EntityBase的匿名实例,
// 此时属性Key仍然未被赋值,随后再调用newEntity实例中Key属性的set方法,修改了Key属性的值
// 然后再返回这个匿名实例
return new TValue
{
Key = key
};
}
}

14.1.7 泛型方法中的转型

有时候使用泛型会造成一次转型操作被“隐藏”,见如下实例,它的作用是将一个流转换成给定类型的对象:

// 注:IFormatter的序列化和返序列化方法有严重漏洞,现已过时,尽量避免使用
[Obsolete("Obsolete")]
private static T CustomDeserialize<T>(Stream stream, IFormatter formatter)
{
return (T)formatter.Deserialize(stream);
}

formatter负责将数据从流中移除并把数据转化为object. 为formatter调用Deserialize会返回object类型的数据,所以需要强制将object转型为T类型,如调用CustomDeserialize<T>,即自定义的Deserialize泛型版本,如:

string greeting = CustomDeserialize<string>(stream, formatter);

这种调用会向外隐藏object向string的强制转型操作,只要是强制转型就有失败的可能,这存在一定的风险。更好的做法是使用非泛型的方法,使开发者注意到它不是类型安全的。

14.2 协变性与逆变性

协变:一个Cat[]也是一个Animal[],具体到泛化

逆变:将一个Animal[]变成一个Cat[],泛化到具体

14.2.1 使用out类型参数修饰符允许协变性

interface IReadOnlyPair<out T>
{
T First {get;}
T Second {get;}
}

用out修饰IReadOnlyPair<out T>接口的类型参数,会导致编译器验证T是否真的只用作“输出”,且永远不用于形参或属性的赋值方法。验证通过后编译器就会放行对接口的任何协变(具体的类=>泛化的类)。

协变转换存在一些重要限制:

  1. 只有泛型接口和泛型委托才可以协变,泛型类和结构体永远不是协变的
  2. 提供给“来源”和“目标”泛型类型的类型实参必须是引用类型,不能是值类型。例如:一个IReadOnlyPair<string>可以协变为IReadOnlyPair<object>,但IReadOnlyPair<int>不能协变为IReadOnlyPair<object>
  3. 接口或委托必须声明支持协变,编译器必须验证协变所针对的类型参数确实只用在“输出”位置。

14.2.2 使用in类型参数修饰符允许逆变性

interface ICompareThings<in T> where T:IComparable<T>
{
bool Compare(T firstElement, T secondElement);
}

和协变性类似,逆变性要求在声明接口的类型参数时使用修饰符in,in指示编译器核对T未在属性的取值方法(getter)中使用,也没有作为方法的返回类型使用。核对无误,就启用接口的逆变转换(泛化的类=>具体的类)。

逆变转换也存在一些类似的限制:

  1. 只有泛型接口和泛型委托才可以逆变,泛型类和结构体永远不是逆变的
  2. 提供给“来源”和“目标”泛型类型的类型实参必须是引用类型,不能是值类型

读书笔记-C#8.0本质论-02的更多相关文章

  1. 《C#本质论》读书笔记(18)多线程处理

    .NET Framework 4.0 看(本质论第3版) .NET Framework 4.5 看(本质论第4版) .NET 4.0为多线程引入了两组新API:TPL(Task Parallel Li ...

  2. 强化学习读书笔记 - 02 - 多臂老O虎O机问题

    # 强化学习读书笔记 - 02 - 多臂老O虎O机问题 学习笔记: [Reinforcement Learning: An Introduction, Richard S. Sutton and An ...

  3. 【英语魔法俱乐部——读书笔记】 0 序&前沿

    [英语魔法俱乐部——读书笔记] 0 序&前沿   0.1 以编者自身的经历引入“不求甚解,以看完为目的”阅读方式,即所谓“泛读”.找到适合自己的文章开始“由浅入深”的阅读,在阅读过程中就会见到 ...

  4. 《The Linux Command Line》 读书笔记02 关于命令的命令

    <The Linux Command Line> 读书笔记02 关于命令的命令 命令的四种类型 type type—Indicate how a command name is inter ...

  5. 《玩转Django2.0》读书笔记-探究视图

    <玩转Django2.0>读书笔记-探究视图 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 视图(View)是Django的MTV架构模式的V部分,主要负责处理用户请求 ...

  6. 《玩转Django2.0》读书笔记-编写URL规则

    <玩转Django2.0>读书笔记-编写URL规则 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. URL(Uniform Resource Locator,统一资源定位 ...

  7. 《玩转Django2.0》读书笔记-Django配置信息

    <玩转Django2.0>读书笔记-Django配置信息 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 项目配置是根据实际开发需求从而对整个Web框架编写相应配置信息. ...

  8. 《玩转Django2.0》读书笔记-Django建站基础

    <玩转Django2.0>读书笔记-Django建站基础 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 一.网站的定义及组成 网站(Website)是指在因特网上根据一 ...

  9. 《C# 6.0 本质论》 阅读笔记

    <C# 6.0 本质论> 阅读笔记   阅读笔记不是讲述这本书的内容,只是提取了其中一部分我认为比较重要或者还没有掌握的知识,所以如果有错误或者模糊之处,请指正,谢谢! 对于C# 6.0才 ...

  10. 《鸟哥的Linux私房菜》读书笔记--第0章 计算机概论 硬件部分

    一个下午看了不少硬件层面的知识,看得太多太快容易忘记.于是在博客上写下读书笔记. 有关硬件 个人计算机架构&接口设备 主板芯片组为“南北桥”的统称,南北桥用于控制所有组件之间的通信. 北桥连接 ...

随机推荐

  1. vue springboot 实现excel导出

    实现excel 导出 一.需求 实现 excel 的导出 二.技术 选用 easypoi 官网: https://gitee.com/lemur/easypoi#http://doc.wupaas.c ...

  2. shell脚本字符串截取方法整理

    首先先声明一个变量str,下面演示以该变量为例: str='https://www.baidu.com/about.html' 1.#号截取,删除左边字符,保留右边字符 echo ${str#*//} ...

  3. vue+webpack工程中怎样在vue页面中引入第三方非标准的JS库或者方法

    方法一:异步加载第三方库 在我们的vue工程中新建如下路径:src/utils/index.js,在index.js中实现如下方法: export function loadScript(url) { ...

  4. ArgoWorkflow教程(四)---Workflow & 日志归档

    上一篇我们分析了argo-workflow 中的 artifact,包括 artifact-repository 配置以及 Workflow 中如何使用 artifact.本篇主要分析流水线 GC 以 ...

  5. Java中使用BigDecimal进行double类型的计算(高精度,可保留几位小数)

    Java中 小数直接进行乘除运算,会出现精度问题导致计算结果有误需要使用 BigDecimal 类型辅助运算,保证精度无误源码: import java.math.BigDecimal;import ...

  6. GZY.Quartz.MUI(基于Quartz的UI可视化操作组件) 2.7.0发布 新增各项优化与BUG修复

    前言 时隔大半年,终于抽出空来可以更新这个组件了 (边缘化了,大概要被裁员了) 2.7.0终于发布了~ 更新内容: 1.添加API类任务的超时时间,可以通过全局配置也可以单个任务设置 2.设置定时任务 ...

  7. [TK] 寻宝游戏

    在树上标记若干个点,求出从某个点走过全部点并回到该点的最小路径. 有多次询问,每次询问只改变一个点. 首先是一个暴力的思路. 会发现,从标记点中的其中一个开始走,结果一定更优,并且无论从哪个点开始走, ...

  8. mysql后台导入sql文件-设定字符集

    需求描述:有一个user_info.sql 的文件里面都是插入user_info表的insert语句数据,数据量500M,要求快速插入mysql的数据库中. 解决方法: 1.利用客户端工具加载文件插入 ...

  9. OOOPS:零样本实现360度开放全景分割,已开源 | ECCV'24

    全景图像捕捉360°的视场(FoV),包含了对场景理解至关重要的全向空间信息.然而,获取足够的训练用密集标注全景图不仅成本高昂,而且在封闭词汇设置下训练模型时也受到应用限制.为了解决这个问题,论文定义 ...

  10. C# Webapi Filter 过滤器 - 生命周期钩子函数 - Action Filter 基础

    ACTION Filter IAsyncACtionFilter 接口 : 1.注入ActionFilter // 注册过滤器 builder.Services.Configure<MvcOpt ...