转换概述

  • 转换(conversion)是接受一个类型的值并将它用作另一个类型的等价的值的过程。
  • 转换后的值应和源值一样,但其类型为目标类型。

隐式转换

有些类型的转换不会丢失数据或精度,而且不会丢失数据。例如,将8位的值转换为16位是非常容易的。

  • 语言会自动做这些转换,这叫作隐式转换。
  • 从位数更少的源类型转换为位数更多的目标类型时,目标中多出来的位需要用0或1填充。
  • 当从更小的无符号类型转换为更大的无符号类型时,目标类型多出来的最高位都以0进行填充,这叫作零扩展(zero extension)。

对于有符号类型的转换而言,额外的最高位用源表达式的符号位进行填充。

  • 这样就维持了被转换的值的正确符号和大小。
  • 这叫作符号扩展(sign extension)。
    ![[csharp-14-conv-1.png]]

显示转换和强制转换

从源类型转换为目标类型时,不发生数据丢失的情况下,C#会自动将一个数据类型转换为另一个数据类型。
如果源类型的任意值在被转换成目标类型时会丢失值,那么C#是不会提供这两种类型的自动转换的。如果希望对这样的类型进行转换,就必须使用显式转换。这叫作强制转换表达式。

1
2
3
4
5
//目标类型
// ↓
(sbyte) var1;
// ↑
// 源表达式

![[csharp-14-conv-2.png]]

1
2
sb:  10 = 0xA
sb: 85 = 0x55

转换的类型

除了标准转换,还可以为用户自定义类型定义隐式转换和显式转换。
还有一个预定义的转换类型,叫作装箱,它可以将任何值类型转换为:

  • object类型。
  • system.ValueType类型。
    拆箱可以将一个装箱的值转换为原始类型。
    ![[csharp-14-conv-3.png]]

数字的转换

任何数字类型都可以转换为其他数字类型,一些转换是隐式的,而另外一些转换则必须是显式的。
![[csharp-14-conv-4.png]]

隐式数字转换

沿着箭头存在一条从源类型到目标类型的路径,则存在从源类型到目标类型的隐式转换。
沿着箭头不存在从源类型到目标类型的路径的数字转换一定是显式转换。
![[csharp-14-conv-5.png]]

溢出检测上下文

显式转换可能会丢失数据并且不能在目标类型中同等地表示源值。对于整数类型,C#允许我们选择运行时是否应该在进行类型转换时检测结果溢出。这将通过checked运算符和 checked语句来实现。
代码片段是否被检查称作溢出检测上下文。

  • 如果指定一个表达式或一段代码为checked,CLR会在转换产生溢出时抛出一个OverflowException异常。
  • 如果代码不是checked,转换会继续而不管是否产生溢出。
    默认的溢出检测上下文是不检查。

checked和unchecked运算符

checked和unchecked运算符控制表达式的溢出检测上下文。表达式放置在一对圆括号内并且不能是一个方法。

1
2
checked(表达式)
unchecked(表达式)
  • 在unchecked上下文中,会忽略溢出,结果值是208。
  • 在checked上下文中,抛出了OverflowException异常。
1
2
3
4
5
6
7
8
ushort sh = 2000;
byte sb;

sb = unchecked ( (byte) sh ); //大多数重要的位丢失了
Console.WriteLine($"sb: { sb }");

sb = checked ( (byte) sh ); //抛出 OverflowException 异常
Console.WriteLine($"sb: { sb }");
1
2
3
4
sb: 208

Unhandled Exception: System.OverflowException: Arithmetic operation resulted in an overflow. at
Test1.Test.Main() in C:\Programs\Test1\Program.cs:line 21

checked语句和unchecked语句

checked和unchecked运算符用于圆括号内的单个表达式。而checked和unchecked语句执行相同的功能,但控制的是一块代码中的所有转换,而不是单个表达式。
checked语句和unchecked语句可以被嵌套在任意层次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
byte sb;
ushort sh = 2000;

checked
{
unchecked
{
sb = (byte) sh;
Console.WriteLine( $"sb: { sb }" );
}

sb = checked((byte) sh);
Console.WriteLine( $"sb: { sb }" );
}

显示数字转换

引用转换

引用类型对象由内存中的两部分组成:引用和数据。

  • 由引用保存的那部分信息是它指向的数据类型。
  • 引用转换接受源引用并返回一个指向堆中同一位置的引用,但是把引用 “标记” 为其他类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A  { public int Field1; }
class B: A { public int Field2; }
class Program
{
static void Main( )
{
B myVar1 = new B();
// 作为A类的引用返回myVar1的引用
A myVar2 = (A) myVar1;

Console.WriteLine($"{ myVar2.Field1 }"); //正确
Console.WriteLine($"{ myVar2.Field2 }"); //编译错误
}
}
// Field2 对于myVar2不可见

隐式引用转换

  • 所有引用类型可以被隐式转换为object类型。
  • 任何接口可以隐式转换为它继承的接口。
  • 类可以隐式转换为:
    • 它继承链中的任何类;
    • 它实现的任何接口。

显示引用转换

显式引用转换是从一个普通类型到一个更精确类型的引用转换。

  • 从object到任何引用类型的转换;
  • 从基类到派生自它的类的转换。
    如果转换的类型不受限制,很容易尝试引用在内存中实际并不存在的类成员。然而,编译器确实允许这样的转换。但如果系统在运行时遇到它们,则会抛出异常。
  • 如果myVar2尝试访问Field2,它会尝试访问对象中“B 部分”的字段(它不在内存中),这会导致内存错误。
  • 运行时会捕获到这种不正确的强制转换并且抛出 InvalidCastException 异常。然而,注意,它不会导致编译错误。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
public int Field1;
}

class B: A {
public int Field2;
}

class Program {
static void Main( )
{
A myVar1 = new A();
B myVar2 = (B)myVar1; // 不安全——在运行时抛出异常
}
}

有效显示引用转换

在运行时能成功进行(也就是不抛出 InvalidCastException 异常)的显式转换有3种情况:
第一种情况:显式转换是没有必要的。也就是说,语言已经为我们进行了隐式转换。例如,在下面的代码中,显式转换是没有必要的,因为从派生类到基类的转换总是隐式转换。

1
2
3
4
5
6
7
class A  { public int Field1; }
class B: A { public int Field2; }

// ...

B myVar1 = new B();
A myVar2 = (A) myVar1; //不必转换,因为 A 是 B 的基类

第二种情况:源引用是null。例如,在下面的代码中,即使转换基类的引用到派生类的引用通常是不安全的,但是由于源引用是null,这种转换还是允许的。

1
2
3
4
5
6
7
class A  { public int Field1; }
class B: A { public int Field2; }

// ...

A myVar1 = null;
B myVar2 = (B) myVar1; //允许转换,因为 myVar1 为空

第三种情况:由源引用指向的实际数据可以安全地进行隐式转换。

1
2
3
4
5
6
7
8
class A  { public int Field1; }
class B: A { public int Field2; }

// ...

B myVar1 = new B();
A myVar2 = myVar1; //将 myVar1 隐式转换为A类型
B myVar3 = (B)myVar2; //该转换是允许的,因为数据是B类型的

装箱转换

包括值类型在内的所有C#类型都派生自 object 类型。然而,值类型是高效轻量的类型,因为默认情况下在堆上不包括它们的对象组件。如果需要对象组件,我们可以使用装箱(boxing)。装箱是一种隐式转换,它接受值类型的值,根据这个值在堆上创建一个完整的引用类型对象并返回对象引用。
需要装箱的一个常见场景是将一个值类型当作参数传递给一个方法,而参数类型是对象的数据类型(或者其他转换需求)。

装箱是创建副本

装箱会拷贝一份值到堆上;栈上原始变量i、堆上装箱对象oi二者完全独立,修改互不影响。

1
2
3
4
5
6
7
8
int i = 10;                    //创建并初始化值类型
// 对 i 装箱并把引用赋值给 oi
object oi = i; //创建并初始化引用类型
Console.WriteLine($"i: { i }, io: { oi }");

i = 12;
oi = 15;
Console.WriteLine($"i:{i}, io: {oi}");
1
2
i: 10, io: 10
i: 12, io: 15

装箱转换

任何值类型ValueTypes都可以被隐式转换为object、System.ValueType或InterfaceT类型(如果ValueTypes实现了InterfaceT)。
![[csharp-14-conv-6.png]]

拆箱转换

拆箱(unboxing)是把装箱后的对象转换回值类型的过程。

  • 拆箱是显式转换。
  • 系统在把值拆箱成ValueTypeT时执行了如下的步骤:
    • 它检测到要拆箱的对象实际是ValueTypeT的装箱值;
    • 它把对象的值复制到变量。
1
2
3
4
5
6
7
8
9
static void Main()
{
int i = 10;
// 对 i 装箱并把引用赋值给 oi
object oi = i;
// 对 oi 拆箱并把值赋值给 j
int j = (int) oi;
Console.WriteLine($"i: { i }, oi: { oi }, j: { j }");
}
1
i: 10,  oi: 10,  j: 10

尝试将一个值拆箱为非原始类型时会抛出 InvalidCastException 异常。

用户自定义转换

除了标准转换,还可以为类和结构定义隐式和显式转换。

  • 除了 implicit 和 explicit 关键字之外,隐式转换和显式转换的声明语法是一样的。
  • 需要 public 和 static 修饰符。
1
2
3
4
5
6
7
8
//必需的
//public&static 隐式或显式 运算符 关键字 源
// ↓ ↓ ↓ ↓ ↓
public static implicit operator TargetType (SourceType Identifier)
{
...
return ObjectOfTargetType;
}
1
2
3
4
5
//将一个Person类型的对象转换为int
public static implicit operator int(Person p)
{
return p.Age;
}

用户自定义转换的约束

用户自定义转换有一些很重要的约束,最重要的如下所示。

  • 只可以为类和结构定义用户自定义转换。
  • 不能重定义标准隐式或显式转换。
  • 对于源类型S和目标类型T,如下命题为真。
    • S和T必须是不同类型。
    • S和T不能通过继承关联。也就是说,S不能派生自T,而T也不能派生自S。
    • S和T都不能是接口类型或object类型。
    • 转换运算符必须是S或T的成员。
  • 对于相同的源类型和目标类型,不能声明两种转换,一个是隐式转换而另一个是显式转换。

用户自定义转换示例

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
class Person
{
public string Name;
public int Age;
public Person(string name, int age)
{
Name = name;
Age = age;
}

//将 person 转换为 int
public static implicit operator int(Person p)
{
return p.Age;
}

//将 int 转换为 person
public static implicit operator Person(int i)
{
return new Person("Nemo", i); // ("Nemo" is Latin for "No one".)
}
}

class Program
{
static void Main( )
{
Person bill = new Person( "bill", 25);

// 把 Person 对象转换为 int
int age = bill;
Console.WriteLine($"Person Info: { bill.Name }, { age }");

// 把 int 转换为 Person 对象
Person anon = 35;
Console.WriteLine($"Person Info: { anon.Name }, { anon.Age }");
}
}
1
2
Person Info: bill, 25
Person Info: Nemo, 35

如果使用 explicit 运算符而不是 implicit 来定义相同的转换,需要使用强制转换表达式来进行转换。

1
2
3
4
5
6
7
8
9
10
11
12
// 显式
public static explicit operator int( Person p )
{
return p.Age;
}
// ...
static void Main( )
{
// ... 需要强制转换表达式
int age = (int) bill;
// ...
}

用户自定义转换在完整转换中最多可以有3个步骤。

  • 预备标准转换;
  • 用户自定义转换;
  • 后续标准转换。
    在这个链中不可能有一个以上的用户自定义转换。

多步用户自定义转换示例

之前内容中的代码示例已经声明了一个从Person类到int的用户自定义转换。如果从Employee到Person以及从int到float有标准转换,我们就可以从 Employee转换到float。

  • Employee继承自Person,从Employee到Person是标准转换。
  • 从int到float是隐式数字转换,也是标准转换。
  • 由于链中的3部分都存在,我们就可以从Employee转换到float。
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
class Employee : Person { }

class Person
{
public string Name;
public int Age;

//将 person 对象转换为 int
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;

// 把 Employee 转换为 float
float fVar = bill;

Console.WriteLine($"Person Info: { bill.Name }, { fVar }");
}
}
1
Person Info: William, 25

is运算符

有些转换是不成功的,并且会在运行时抛出 InvalidCastException 异常。我们可以使用 is 运算符来检查转换是否会成功完成,从而避免盲目尝试转换。
is 运算符的语法如下,其中Expr是源表达式:

1
2
3
4
//    返回 bool
// ↓
//————————————————
Expr is TargetType

如果Expr可以通过以下方式成功转换为目标类型,则运算符返回true:

  • 引用转换
  • 装箱转换
  • 拆箱转换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Employee : Person { }
class Person
{
public string Name = "Anonymous";
public int Age = 25;
}

class Program
{
static void Main()
{
Employee bill = new Employee();

//检测变量 bill 是否能转换为 Person 类型
if( bill is Person )
{
Person p = bill;
Console.WriteLine($"Person Info: { p.Name }, { p.Age }");
}
}
}

is 运算符只可以用于引用转换以及装箱和拆箱转换,不能用于用户自定义转换。

as运算符

as运算符和强制转换运算符类似,只是它不抛出异常。如果转换失败,它返回 null 而不是抛出异常。
as运算符的语法如下,其中:

  • Expr 是源表达式;
  • TargetType 是目标类型,它必须是引用类型。
1
2
3
4
//     返回引用
// ↓
//————————————————
Expr as TargetType

由于 as 运算符返回引用表达式,它可以用作赋值操作中的源。
例如,我们使用 as 把 Employee 类型的变量 bill 转换为 Person 类型,并且赋值给一个 Person 类型的变量 p。在使用它之前应该检查 p 是否为 null。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Employee : Person { }

class Person
{
public string Name = "Anonymous";
public int Age = 25;
}

class Program
{
static void Main()
{
Employee bill = new Employee();
Person p;

p = bill as Person;
if( p != null )
{
Console.WriteLine($"Person Info: { p.Name }, { p.Age }");
}
}
}

与 is 运算符类似,as 运算符只能用于引用转换和装箱转换,不能用于用户自定义转换或到值类型的转换。