65.9K
CodeProject 正在变化。 阅读更多。
Home

动态方法和 CLR 的一些乐趣 (第 2 部分)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (26投票s)

2018 年 9 月 17 日

CPOL

14分钟阅读

viewsIcon

46833

downloadIcon

300

使用 CLR 和由动态程序集创建的方法做一些疯狂而危险的事情

引言

这个周末,我使用 DynamicAssemblyILGenerator 编写了一个微小的泛型方法 DirectCast,这为神秘的 CLR 内部打开了一个小小的窗口。

本部分包含以下章节。欢迎任何批评。

  1. 微小、恶毒的 DirectCast 方法
  2. 似是而非、似非而是的布尔值
  3. 任务:修改私有字段
  4. 它是如何工作的?
  5. 为了协变性而强制转换集合
  6. 关注点
  7. 历史

微小、恶毒的 DirectCast 方法

本文讨论的这个微小方法 DirectCast<TX, TY> 可以借助 DynamicAssembly 类和不到 20 行的代码来构建。它就在这里

static void Main(string[] args) {
    const string FileName = "ClrHacker.dll";
    var a = AppDomain.CurrentDomain
       .DefineDynamicAssembly(new AssemblyName("ClrHacker"), AssemblyBuilderAccess.RunAndSave);
    var mod = a.DefineDynamicModule(FileName);
    var type = mod.DefineType("ClrHacker", 
       TypeAttributes.Public | TypeAttributes.Sealed | 
       TypeAttributes.Abstract | TypeAttributes.Class);
    CreateDirectCastMethod(type);
    type.CreateType();
    a.Save(FileName);
    Console.WriteLine(FileName + " was saved. Now you can reference it in other projects.");
}

CreateDirectCastMethod 列在下面

static void CreateDirectCastMethod(TypeBuilder type) {
    var m = type.DefineMethod("DirectCast", MethodAttributes.Public | MethodAttributes.Static);
    var g = m.DefineGenericParameters("TX", "TY");
    m.SetParameters(g[0]);
    m.SetReturnType(g[1]);
    var il = m.GetILGenerator();
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ret);
}

构建并运行程序。您将得到一个名为 ClrHacker.dll 的小型程序集,其中包含一个名为 DirectCast 的泛型方法,它位于 ClrHacker 类中,在 ILSpy 中看起来像这样。这个小方法 DirectCast 是本文的主角。它接受 TX 的一个实例并将其作为 TY 返回。

public static TY DirectCast<TX, TY>(TX P_0)
{
    return (TY)P_0;
}

您永远无法通过 C# 编译器构建这样的方法,因为它不允许您直接转换 P_0,其类型为未知的 TX,到另一个未知的类型 TY,而这两个类型参数都没有任何限制或关系。

您也无法像这样构建一个工作的 DynamicMethod,因为如果您使用 CreateDirectCastMethod 中的代码构建一个类似的 DynamicMethod,在运行该方法时会抛出一个验证异常。

然而,当我构建了一个 DynamicAssembly,将其保存到磁盘,并在另一个项目中引用它时,上述方法竟然能够运行并产生了一些炫酷的结果。

似是而非、似非而是的布尔值

许多 C# 程序员都知道,Boolean 值只能是 truefalse。怎么可能同时既不是 true 也不是 false 呢?

它是如何发生的。

True 和 False 的底层值

使用 DirectCast<TX, TY>,我们可以将 boolean 值转换为整数值。让我们运行以下代码,我们将在控制台上看到 t(rue) = 1f(alse) = 0

var t = ClrHacker.DirectCast<bool, byte>(true);
var f = ClrHacker.DirectCast<bool, byte>(false);
Console.WriteLine("t(rue) = " + t.ToString());
Console.WriteLine("f(alse) = " + f.ToString());

实际上,CLR 中的布尔值是 10

更多的布尔值及其之间的比较

反过来,我们可以使用 DirectCast<TX, TY> 来创建其他一些 bool 值,方法是将整数值转换为 bool

var TRUE = true;
var FALSE = false;

bool b8 = ClrHacker.DirectCast<byte, bool>(8);
Console.WriteLine("b8 = " + b8.ToString());
Console.WriteLine("b8 == true: " + (b8 == true).ToString());
Console.WriteLine("b8 == false: " + (b8 == false).ToString());
Console.WriteLine("b8 == TRUE: " + (b8 == TRUE).ToString());
Console.WriteLine("b8 == FALSE: " + (b8 == FALSE).ToString());

在上面的代码中,我们首先将局部变量 TRUEFALSE 分配给相应的 bool 值,然后直接将 **数字 8** 转换为 bool 值并存储到局部变量 b8 中。结果如下所示

b8 = True
b8 == true: True
b8 == false: False
b8 == TRUE: False
b8 == FALSE: False

这是结果的解读

  1. b8 (请记住它实际上是 8) 执行 Boolean.ToString 方法,它打印 True。这是预期的。
  2. (b8 == true).ToString 将被编译成执行与 b8.ToString 相同的事情。(您可以对此以及下面的内容进行验证,可以使用 ILSpy)。
  3. (b8 == false).ToString 将被编译成加载 b8 和 **数字 0** 到评估堆栈上,并执行 ceq (检查相等性) 操作。
  4. (b8 == TRUE) 将被编译成加载 b8Boolean 变量 TRUE 到评估堆栈上,并执行 ceq 操作。
  5. (b8 == FALSE) 也会发生类似的事情。

检查最后两行,我们会看到存在一个 bool 值,它既不等于现有的 true 值,也不等于另一个现有的 false 值。

现在我们用 **数字 7** 再次创建另一个 *数值布尔值*。

bool b7 = ClrHacker.DirectCast<byte, bool>(7);
Console.WriteLine("b7 = " + b7.ToString());
Console.WriteLine("b7 == true: " + (b7 == true).ToString());
Console.WriteLine("b7 == false: " + (b7 == false).ToString());
Console.WriteLine("b7 == TRUE: " + (b7 == TRUE).ToString());
Console.WriteLine("b7 == FALSE: " + (b7 == FALSE).ToString());

猜猜您会看到什么,并与输出进行比较。

b7 = True
b7 == true: True
b7 == false: False
b7 == TRUE: False
b7 == FALSE: False

如果您已经习惯了似是而非、似非而是的布尔值,那么这里就没有什么新鲜事了。那么,如何比较 b8b7 呢?

Console.WriteLine("b8 = " + b8.ToString());
Console.WriteLine("b7 = " + b7.ToString());
Console.WriteLine("b8 == b7: " + (b8 == b7).ToString());

输出显示 b8b7 都打印 True 到控制台,但它们不相等。原因其实非常简单,它们实际上是不同的数字。

b8 = True
b7 = True
b8 == b7: False

证明布尔值是单字节

sizeof(bool) 会告诉您,一个 bool 值是单个字节。

使用 DirectCast,我们编写以下代码

bool b15 = ClrHacker.DirectCast<int, bool>(15);
bool b255 = ClrHacker.DirectCast<int, bool>(255);
bool b1023 = ClrHacker.DirectCast<int, bool>(1023);
bool b1024 = ClrHacker.DirectCast<int, bool>(1024);
Console.WriteLine("b255 == b15: " + (b255 == b15).ToString());
Console.WriteLine("b255 == b1023: " + (b255 == b1023).ToString());
Console.WriteLine("b255 == b1024: " + (b255 == b1024).ToString());

这里,我们有四个 bool 值,分别由数字 1525510231024 创建。选择这四个数字的原因是,前两个在字节范围内,而后两个需要两个字节。在执行 DirectCast<int, bool> 时,后两个将被截断为单个字节,如果 bool 类型是单字节的话。

结果如下

b255 == b15: False
b255 == b1023: True
b255 == b1024: False

由于 1023 (二进制 1 1111 1111) 被截断为 255 (二进制 1111 1111),所以比较返回 True。而 1024 (二进制 10 0000 0000) 被截断为 0,比较返回 False

为了证明这一点,我们可以执行以下代码将这些 bool 值转换回 int

Console.WriteLine("(int)b1023 = " + ClrHacker.DirectCast<bool, int>(b1023).ToString());
Console.WriteLine("(int)b1024 = " + ClrHacker.DirectCast<bool, int>(b1024).ToString());

我们将看到 1023 被截断为 255

(int)b1023 = 255
(int)b1024 = 0

有关布尔值内部机制的更多信息可以在这里阅读:C# 中布尔值的大小是多少?它真的占用 4 个字节吗?

任务:修改私有字段

几乎所有的 C# 程序员都知道,private 字段在定义它们的类之外是无法访问的,但它们可以通过反射、动态方法或 P/Invoke 封送来修改。

使用 DirectCast,还有另一种方法。没有反射,没有动态方法,没有封送。

警告

我们将探索 CLR 中隐藏的一个 **危险** 部分!

我们这里进行的操作可能会完全导致应用程序崩溃。

我不建议您在生产环境中使用此技巧。

好公民——类 Me

我们从一个好公民开始,一个普通的类 Me,它包含两个 public 属性(以及编译器相应生成的两个后备字段)和一个 public 方法。

sealed class Me
{
    public DateTime Date { get; set; }
    public string Word { get; set; }
    public void Tell() {
        Console.Write("Me: ");
        Console.WriteLine(Date.ToShortDateString() + "(" + Date.Ticks.ToString("X16")
           + "," + Date.Ticks.ToString() + "): " + Word);
    }
}

可怜害羞的类 A

“害羞的类” AMe 的“双胞胎姐妹”。A 拥有与 Me 相同数量和类型的字段,还有一个 public 方法。但是 A 中的所有字段都是 private,其构造函数也是如此。

sealed class A
{
    DateTime value;
    string text;
    private A() { }
    public void Print() {
        Console.Write("A: ");
        Console.WriteLine(value.ToString() + ", " + text);
    }
}

直接转换 Me 到 A

通常,A 是如此 private 以至于它无法初始化且无法修改。但是恶毒的 DirectCast 方法为我们打开了一个后门。

最初,我们实例化一个 Me 的实例并将其赋给局部变量 me

Me me = new Me { Date = new DateTime(1997, 7, 1), Word = "Hello world!" };
me.Tell();

之后,我们使用 DirectCast 将变量 me 转换为另一个变量 a,类型为 A

// make a reference to me
A a = ClrHacker.DirectCast<Me, A>(me);
a.Print();

控制台上的结果显示

Me: 1997/7/1(08BE53C8DA54C000,630033120000000000): Hello world!
A: 1997/7/1 0:00:00, Hello world!

我们从未给 a 赋任何字段值。但是,它打印了与 me 相同的值。

提问

我们能说我们曾经实例化过类 A 的实例吗?(答案在下面)

更改私有字段

现在,我们将使类 A 中的 Print 方法打印另一个内容,而无需触碰变量 a

我们更改了好公民 me 的值。

me.Date = new DateTime(1999, 12, 31);

我们还可以选择调用 meTell 方法和 aPrint 方法,以验证更改。

me.Tell();
a.Print();

结果如下

Me: 1999/12/31(08C121391D7A8000,630821952000000000): Hello world!
A: 1999/12/31 0:00:00, Hello world!
提问

好公民 Me 是否被利用来做坏事——操纵不可触及的 A

它是如何工作的?

在这种情况下,类 Me 和类 A 都是引用类型。在 CLR 中,**引用类型是指向内存地址的指针**。

C 程序员非常清楚,如果两个 *C 结构体* 具有相同的内存布局,那么它们可以来回转换。

对于 CLR 的 class(引用类型),似乎也是如此。变量 me 和变量 a 实际上指向相同的内存槽。我们可以说,当我们改变变量 me 指向的内存槽的 Date 属性时,我们也改变了变量 a 指向的相应字段。

未伪装的视图

Visual Studio 中的调试器将揭示 a 底层信息仍然是一个 Me 类。

为什么调试器知道变量 a 是伪装的 Me 类,而 CLR 仍然假装变量 aA 类呢?

这是因为 .NET CLR 中对象的内存布局与原生 C 程序不同。每个 .NET 对象都有一些开销,称为 *对象头* 和 *方法表指针*(详情请参阅托管对象内部,第 1 部分。布局)。尽管我们通过让两个不同的变量指向同一数据槽成功地欺骗了 CLR,但调试器仍然可以通过检查对象头来发现变量 a 的底层类型。同时,如果我们运行以下代码,我们也会在控制台上读到“ClrFun2.Me”。

Console.WriteLine(a.GetType().FullName)

因此,在上面的章节中,我们实际上是在处理伪装的 Me 实例,它看起来像另一个类。

是否有可能实例化一个 A 的实例并更改其字段值?是的。

通过公共“虫洞”修改私有字段

在基本类库中,有一个方法 FormatterServices.GetUninitializedObject,它可以创建一个未初始化的对象。因此,我们可以使用它来创建一个 A 的实例——分配相应的内存槽。然后我们 DirectCast AMe 并更改 Mepublic 属性。A 实例中的字段将同时更改。

var u = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(A)) as A;
me = ClrHacker.DirectCast<A, Me>(u); // open a public wormhole
Console.WriteLine(u.GetType().FullName);
u.Print();
me.Tell();
me.Date = new DateTime(1999, 12, 25); // change me, change u
me.Word = "Happy X'Mas";
u.Print();
me.Tell();

控制台输出如下

ClrFun2.A
A: 0001/1/1 0:00:00,
Me: 0001/1/1(0000000000000000,0):
A: 1999/12/25 0:00:00, Happy X'Mas
Me: 1999/12/25(08C11C821F000000,630816768000000000): Happy X'Mas

现在我们成功地初始化了一个无法公开初始化的对象并为其赋值。

DirectCasted 的原始值类型

我们已经了解到 DirectCast 方法可以伪装引用类型。那么值类型呢?

在这篇文章的前一章中,我们已经将整数类型 DirectCast 到了布尔类型。GetType 函数会像这样揭露它吗?

让我们用以下代码进行测试

bool b = ClrHacker.DirectCast<int, bool>(7);
Console.WriteLine(b.GetType().FullName);

我们将在控制台上读到“*System.Boolean*”,而不是“System.Int32”。为什么 GetType 函数未能揭示 DirectCasted 值?

如果我们用 ildasm 反编译上面的代码,我们可以读到以下关于在调用 GetType 之前发生的事件的 IL 代码。

ldc.i4.7
call !!1 ClrHacker::DirectCast<int32, bool>(!!0)
box [mscorlib]System.Boolean
call instance class [mscorlib]System.Type [mscorlib]System.Object::GetType()

第一行将 Int32 数字 7 加载到评估堆栈上。

第二行消耗堆栈上的数字并调用 DirectCast 方法,该方法将值推回评估堆栈。

任何值类型都必须先 box 成一个引用实例,然后才能访问其成员。.NET CLR 中。要 box 一个值类型,必须提供其类型。因此,C# 编译器使用变量 bBoolean 类型的信息,并生成指令来 box DirectCast 的返回值,然后 call GetType 函数。

如果变量 b 是引用类型,则不会发生 box 操作。因此,在调用 GetType 之前没有类型信息交换,从而返回变量的原始类型,如上一节所示。

修改不可变值类型实例

让我们再举一个例子,将自定义值类型与原始类型进行转换

long b = ClrHacker.DirectCast<DateTime, long>(DateTime.MaxValue);
Console.WriteLine(b);

上面的例子将因以下消息而崩溃。

Unhandled Exception: System.InvalidProgramException: Common Language Runtime detected 
an invalid program.

C/C++ 程序员可能会认为,既然在不安全上下文中 sizeof(DateTime) 返回 8,这与 sizeof(long) 的结果相同,那么这两种类型应该可以 DirectCast。然而,这在 CLR 世界中是行不通的。

如果我们用下面的代码将 long 类型替换为另一个自定义值类型 DateTimeStruct,它就可以工作。

struct DateTimeStruct
{
    public int T, U;
}

DateTimeStruct b = ClrHacker.DirectCast<DateTime, DateTimeStruct>(DateTime.MaxValue);
急需帮助

如果有人知道为什么它行不通,请评论。

要将 DateTime 实例 DirectCastlong 类型,我们必须使用另一个版本的 DirectCast,它可以由以下代码构建。方法体与先前版本相同,唯一的区别是参数和返回类型都更改为 ref 类型。随后,此方法可以为值类型实例打开“虫洞”(引用)。

// creates: ref TY DirectCast<TX, TY>(ref TX)
static void CreateRefDirectCastMethod(TypeBuilder type) {
  var m = type.DefineMethod("DirectCast", MethodAttributes.Public | MethodAttributes.Static);
  var g = m.DefineGenericParameters("TX", "TY");
  m.SetParameters(g[0].MakeByRefType());
  m.SetReturnType(g[1].MakeByRefType());
  var il = m.GetILGenerator();
  il.Emit(OpCodes.Ldarg_0);
  il.Emit(OpCodes.Ret);
}

然后,我们可以使用以下代码来利用新的 DirectCast 方法。

var t = DateTime.MaxValue;
ref long b = ref ClrHacker.DirectCast<DateTime, long>(ref t);
// or
// long b = ClrHacker.DirectCast<DateTime, long>(ref t);
Console.WriteLine($"b = {b}");

我们将在控制台上看到以下内容,这与 DateTime.MaxValue.Ticks 的值相同,大约是 DateTime 的内部值。

b = 3155378975999999999

ref 关键字将 DateTime 的地址传递给 DirectCast 方法,然后当 DirectCast 返回时,ref long b 保持对 DateTime 结构数据部分的引用。

因此,我们可以通过操作变量 b 提供的“虫洞”来更改 t 的值,而不直接接触它。以下代码片段演示了这一点。

var t = DateTime.MaxValue;
ref long b = ref ClrHacker.DirectCast<DateTime, long>(ref t);
// or
// long b = ClrHacker.DirectCast<DateTime, long>(ref t);
Console.WriteLine($"b = {b}");
Console.WriteLine($"t = {t}");

Console.WriteLine("Changing b...");
b = TimeSpan.TicksPerHour + TimeSpan.TicksPerMinute + TimeSpan.TicksPerSecond;

Console.WriteLine($"b = {b}");
Console.WriteLine($"t = {t}");

我们将在控制台上看到 t 的值已从 9999/12/31 23:59:59 更改为 0001/1/1 1:01:01

b = 3155378975999999999
t = 9999/12/31 23:59:59
Changing b...
b = 36610000000
t = 0001/1/1 1:01:01

为了协变性而强制转换集合

DirectCast 也可用于转换集合类型。

警告

这也很 **危险**!如果它导致应用程序崩溃,它就崩溃了。您没有任何机会捕获任何异常,也看不到任何堆栈跟踪或 Windows 事件查看器中的异常消息。

List<T> 不支持协变性或逆变性

在以下示例中,有一个 BaseClassList<T>,但其中的所有项都是 SubClass 类型,它是 BaseClass 的派生类。

var b = new List<BaseClass> {
    new SubClass { N = 1, Prefix = "A", S2 = "START" },
    new SubClass { N = 2, Prefix = "B" },
    //new BaseClass { N = 3 },
    new SubClass { N = 4, Prefix = "D", S1 = "END" },
};

BaseClassSubClass 的定义如下

class BaseClass
{
    public int N { get; set; }
}
sealed class SubClass : BaseClass
{
    public string Prefix { get; set; }
    public string S1 { get; set; }
    public string S2 { get; set; }
}

强制逆变性的 DirectCasting

由于 List<T> 不支持协变性或逆变性,因此无法隐式或显式地将 List<BaseClass> 转换为 List<SubClass>。使用 DirectCast,您可以做到这一点,如下面的代码所示

foreach (SubClass item in ClrHacker.DirectCast<List<BaseClass>, List<SubClass>>(b)) {
    Console.WriteLine(String.Join(",", item.Prefix, item.N, item.S1, item.S2));
}

取消注释初始化变量 b 的代码片段中的第 4 行(//new BaseClass { N = 3 }),程序在运行到第 3 项时可能会崩溃,该项不是 SubClass 而是 BaseClass

崩溃的原因有点复杂但又很简单。

  1. List<SubClass> 中的第 3 项实际上是一个 BaseClassSubClass 需要额外的字节来填充其额外的属性(PrefixS1S2)——这些字段确实存在,但它们在 BaseClass 中不存在。
  2. 填充这些字段将导致 CLR 读取超出 BaseClass 实例占用的内存槽的范围。通常不会导致应用程序崩溃,如果 SubClass 的额外字段不超过应用程序内存的边界。CLR 只是将一些“垃圾字节”读入这些额外字段。
  3. 非常不幸的是,在这个例子中,SubClass 的额外字段都是 String,而 .NET String 的内存布局前面有一个数字,表示 String 的长度。因此,“垃圾字节”可能告诉 CLR 这三个 String 的长度可能是非常大的数字。
  4. 当调用上面代码片段中的 Console.WriteLine 时,CLR 不得不访问 item.Prefixitem.S1item.S2 的内容,这些属性的错误长度将导致 CLR 读取超出操作系统分配给应用程序的内存边界,从而导致执行引擎崩溃。

DirectCasted 项目在枚举中的奇怪行为

枚举器生成的 DirectCasted item 的行为有点特别。您既不能将其与 null 进行比较,也不能测试它是否 is SubClass 类型。

var b = new List<BaseClass> {
    new SubClass { N = 1, Prefix = "A", S2 = "START" },
    new SubClass { N = 2, Prefix = "B" },
    new BaseClass { N = 3 },
    new SubClass { N = 4, Prefix = "D", S1 = "END" },
};

foreach (SubClass item in ClrHacker.DirectCast<List<BaseClass>, List<SubClass>>(b)) {
    if (item == null || item is SubClass == false) {
        continue; // this cannot be used to filter out the BaseClass instance
    }
    Console.WriteLine(String.Join(",", item.Prefix, item.N, item.S1, item.S2));
}

运行上面的代码片段,程序很可能仍然会崩溃而不会抛出任何异常。

**注意**:为什么这个例子中 item is SubClass == false 不起作用是因为编译器已经知道从 foreach 语句来看 item 必须是 SubClass 类型,因此它只是将 is 操作更改为与 null 的比较。用 ILSpy 反编译上面的代码,您会看到这一点。

通过类型比较防止崩溃

正如我们之前所知,对 DirectCasted 实例调用 GetType 方法可以揭示其真实类型。防止崩溃的一种方法是强制进行类型比较并跳过不是 SubClass 的项。

foreach (SubClass item in ClrHacker.DirectCast<List<BaseClass>, List<SubClass>>(b)) {
    if (item.GetType() != typeof(SubClass)) {
        continue;
    }
    Console.WriteLine(String.Join(",", item.Prefix, item.N, item.S1, item.S2));
}

当然,这很麻烦,不完整(我们也通过上述方式过滤了 SubClass 的子类),而且效率低下,与不使用 DirectCasted List 的版本相比。与不使用 DirectCasted List 的版本相比,这既麻烦又不完整(我们也过滤了 SubClass 的子类),而且效率低下。

foreach (BaseClass item in b) {
    var sub = item as SubClass;
    if (sub == null) {
        continue;
    }
    Console.WriteLine(String.Join(",", sub.Prefix, sub.N, sub.S1, sub.S2));
}

只有当您 100% 确定项的类型正确时,才应该使用对集合的 DirectCast。它实在太危险了。如果您的代码中散布着许多对 DirectCast 的调用,您将很难找出崩溃的根源。

关注点

  1. 对伪装的 DirectCast 对象调用 GetType() 将揭示其真实类型。但是对于值类型对象,在 DirectCast 之后会创建一个目标类型的新实例。那么,将小值类型(如 intDirectCast 到大值类型(如 Guid)是否安全?
  2. 如果我们将一个小类 DirectCast 到一个大类,很有可能会抛出 AccessViolationException,因为后者需要更多的内存槽,而前者没有。但是如果我们把一个大类 DirectCast 到一个小类会发生什么?
  3. 看起来我们可以通过将引用对象 DirectCastlong 来快速获取引用类型实例的内存地址,例如 DirectCast<string, long>(anInstanceOfString)
  4. 实际上,这种技巧已经存在很长时间了。我们可以在 .NET Core 库之外的程序集 System.Runtime.CompilerServices.Unsafe 中找到更多信息。源代码可以在 GitHub 上找到,其中包含 CS 伪装和相应的 IL 实现。可以通过 NuGet 下载程序集库。他们使用 IL 和 ilasm 来制作这样一个库。而在本文中,我们通过 DynamicAssembly 实现了类似的功能,并且我们得到的程序集甚至可以移植到更早的 .NET 平台,最早可以追溯到 .NET Framework 2.0。

历史

  1. 2018-9-17:初次发布
  2. 2018-9-19:+ DirectCast 集合
  3. 2019-4-16:添加了关于类似实现和 .NET Core 库更多功能的信息
  4. 2019-10-23:添加了关于直接转换原始值类型的信息
  5. 2019-10-24:通过 DirectCast 方法的重载添加了关于直接转换自定义值类型到原始值类型的信息
© . All rights reserved.