C#类型转换

发布于:2025-02-10 ⋅ 阅读:(22) ⋅ 点赞:(0)

C#是静态类型的语言,变量一旦声明就无法重新声明或者存储其他类型的数据,除非进行类型转换。本章的主要任务就是学习类型转换的知识。类型转换有显式的,也有隐式的。所谓显式,就是我们必须明确地告知编译器,我们要把变量从源类型转换成什么类型;而隐式的则不需要,编译器会自动帮我们进行转换。知道装箱和拆箱吗?我们将在本文中学习装箱和拆箱的知识。


1 隐式类型转换

什么是隐式转换呢? 如果编译器认为从类型1(下称T1)到类型2(下称T2)的转换不会产生不良后果,那么T1到T2的转换就是由编译器自动完成的,这就是隐式转换。我们举个例子,如代码清单5-1所示。

代码清单5-1 隐式类型转换
namespace ProgrammingCSharp4
{
    class TypeConvert
    {
        private void DoSomething()
        {
            int intValue = 10;
            long longValue = intValue;
        }
    }
}

第8行执行的是int型到long型的转换,long型对应的是System.Int64int型对应的是System.Int32,显然long型的取值范围要比int型大,因此这种转换是安全的,编译器允许了此次转换。为了了解类型转换的实质,我们可以通过查看上述代码编译后生成的CIL代码,如代码清单5-2所示。

代码清单5-2 CIL代码
.method private hidebysig instance void DoSomething() cil managed
{
    // Code size 8 (0x8)
    .maxstack 1
    .locals init([0] int32 intValue, [1] int64 longValue)
    IL_0000: nop
    IL_0001: ldc.i4.s 10
    IL_0003: stloc.0
    IL_0004: ldloc.0
    IL_0005: conv.i8
    IL_0006: stloc.1
    IL_0007: ret
} // end of method TypeConvert:DoSomething

为了突出重点,我们先忽略其他不相关内容,只关注与类型转换相关的CIL指令。

  • 第7行:类型为i4(即int32)的数据10,入栈;

  • 第8行:出栈,赋予变量[0],即intValue

  • 第9行:变量0数据入栈;

  • 第10行:将栈顶中的数据转换为i8类型(即int64,也就是long类型);

  • 第11行:出栈,赋予变量[1],即longValue

其中,最重要的是第11行,编译器生成了类型转换的CIL指令:

conv.<to type>

<to type>就是要转换到的目标类型。

可见,查看CIL代码有助于我们了解编译器所做的实际操作,有助于我们更加深刻地理解C#这门语言,以及.NET CLR的一些工作机制。在本书的其他章节,我们还会通过CIL代码来进行学习。由于CIL的知识超出了本文的范围,需要进一步了解CIL的读者,可以自行查阅其他资料。

大家现在应该对隐式类型转换有了初步的了解,接下来将进一步学习数值类型的隐式转换,以及引用类型中的隐式转换。


1.1 数值类型

C#语言支持的数值类型的隐式转换如下所示:

  1. sbyteshortintlongfloatdoubledecimal

  2. byteshortushortintuintlongulongfloatdoubledecimal

  3. shortintlongfloatdoubledecimal

  4. ushortintuintlongulongfloatdoubledecimal

  5. intlongfloatdoubledecimal

  6. uintlongulongfloatdoubledecimal

  7. longfloatdoubledecimal

  8. ulongfloatdoubledecimal

  9. charushortintuintlongulongfloatdoubledecimal

  10. floatdouble

上述的隐式转换是安全的,不会造成任何精度或者数量级的损失。需要说明的是,C#不支持任何其他类型到char类型的隐式转换。

有两种特殊的隐式转换需要说明,之所以说它们特殊,是因为它们会带来精度损失,但没有数量级损失,它们是:

  1. intuintlong或者ulongfloat的转换;

  2. long或者ulongdouble的转换。

我们还是以一段代码为例,演示从intfloat的类型转换,以此演示精度损失的情况,如代码清单5-3所示。

代码清单5-3 精度损失示例
using System;

namespace ProgrammingCSharp4
{
    class TypeConvert
    {
        static void Main(string[] args)
        {
            TypeConvert typeConvert = new TypeConvert();
            typeConvert.DoSomething();
        }

        public void DoSomething()
        {
            int max = int.MaxValue;
            float floatValue = max;
            Console.WriteLine(max);
            Console.WriteLine(floatValue);
        }
    }
}

上述代码打印了int类型支持的最大有效值(MaxValue),然后将它赋予了一个float类型的变量floatValue,编译器执行了隐式转换。运行结果是:

2147483647
2.147484E+09

这里2.147484E+09表示科学计数法,相当于2.147484 × 10^9,也就是2,147,484,000

可以看出,转换后的数值比原值的有效位减少了,因为原值是2,147,483,647(有效位:10),转换后的值是2,147,484,000(有效位:7),很显然,类型转换造成了精度损失,但数量级并没有损失。至于另外一种情况——从longulongdouble的转换,请大家自行验证。

我们在学习程序设计时一定要重视实验(注意不是“试验”,而是实地验证),将书本或者课题上讲的内容、知识点进行实际验证。这可以加深我们对知识的理解,同时也能积累解决问题的方式和方法。

下一节我们将讲述引用类型的隐式转换。


1.2 引用类型

符合以下情况之一者,编译器可以自动实施隐式类型转换,并且不需要运行时类型检查:

  1. 任意引用类型到object类型的转换;

  2. 派生类型到基类型的转换;

  3. 派生类型到其实现的接口类型的转换;

  4. 派生接口类型到基接口类型的转换;

  5. 数组类型到System.Array类型的转换;

  6. 委托类型到System.Delegate类型的转换;

  7. null类型到所有引用类型的转换。

对于引用类型来说,无论是隐式还是显式的类型转换,改变的仅仅是引用的类型,至于该引用指向的对象的类型以及对象的值都是保持不变的。如图5-1所示,它实际改变的是变量1的类型,而引用的对象“对象1”则保持类型和值不变。


1.3 装箱

之所以再次讨论装箱,是因为装箱也属于类型转换的知识范畴。我们先来看一段示例代码,如代码清单5-4所示。

代码清单5-4 装箱
namespace ProgrammingCSharp4
{
    class Boxing
    {
        public void DoSomething()
        {
            int x = 10;
            object obj = x;
        }
    }
}

第7行声明了一个int型变量x,并初始化为10。接着第8行声明了一个object类型obj,并使用x为其初始化,这里既是装箱,也是本文讲的类型转换,其本质还是类型转换,即将int型“装箱”为object类型,这个装箱的过程即是隐式的类型转换。

我们仍然通过查看上述代码编译生成的CIL代码来观察装箱的具体过程,CIL代码如代码清单5-5所示。

代码清单5-5 DoSomething()函数的CIL代码
.method public hidebysig instance void DoSomething() cil managed
{
    // Code size 12 (0xc)
    .maxstack 1
    .locals init (
        [0] int32 x,
        [1] object obj
    )
    IL_0000: nop
    IL_0001: ldc.i4.s 10
    IL_0003: stloc.0
    IL_0004: ldloc.0
    IL_0005: box [mscorlib]System.Int32
    IL_000a: stloc.1
    IL_000b: ret
} // end of method Boxing:DoSomething

这里只关注与装箱相关的代码,对CIL有兴趣的读者可以自行查找相关资料进行学习。

代码清单5-5的第13行是重点,box指令指示把栈中的int型(值类型)变量装箱为引用类型(object)。经过装箱这一过程后,原来的值类型的变量就不存在了,取而代之的就是装箱后的引用类型的变量。

另外,枚举类型经过装箱以后成为System.Enum类型,因为System.Enum类是枚举类型的基类。而结构类型和枚举类型装箱后则为System.ValueType类型,原因一样,因为System.ValueType类型是所有结构类型和枚举类型的基类。

在本节的最后,我们对装箱的类型转换做个总结,如下:

  1. 值类型可隐式转换到object类型或System.ValueType类型;

  2. Nullable值类型可隐式转换到它实现的接口;

  3. 枚举类型可隐式转换到System.Enum类型。


2. 显式类型转换

显式类型转换又叫做显式强制类型转换、强制类型转换,因为不能自动进行转换(和隐式类型转换相比而言),因而需要显式地告知编译器需要类型转换。隐式类型转换往往是由窄向宽的转换,而显式类型转换恰恰相反,是由宽向窄的类型转换。以数值类型为例,从一个取值范围更大的类型向较小的类型转换时,由于可能导致精度损失或引发异常,因此编译器不会自动进行隐式转换,除非明确告知。因此,显式转换也称为收缩转换。

那么,该如何告诉编译器我们确定要做这种显式的转换呢?很简单,只需要在变量前使用一对小括号()运算符,小括号中是目标类型。如果未定义相应的()运算符,则强制转换会失败。以后我们还将学到,还可以使用as运算符进行类型转换,如代码清单5-6所示。

代码清单5-6 long类型到int类型的转换
using System;

namespace ProgrammingCSharp4
{
    class TypeConvert
    {
        public void DoSomething()
        {
            long longValue = 10;
            int intValue = (int)longValue;
        }
    }
}

编译上述代码,编译器会产生如下编译错误:

无法将类型“long”隐式转换为“int”。存在一个显式转换(是否缺少强制转换?)

分析一下为什么会产生这样的错误,在代码的第10行,我们试图将取值范围更大的long类型隐式地转换为int类型。前面讲过,这可能会造成信息丢失,因此编译器将之作为一个错误,并拒绝进行转换。如果确实要进行转换,就需要显式类型转换了,即使用()运算符或者as运算符。知道了错误的原因,那么只需对第10行做如下修改即可解决问题:

int intValue = (int)longValue;

这里的()运算符(int)明确告知编译器需要将long转换为int。至此,问题解决。

其实,所有的隐式类型转换都可以显式地进行类型转换。因此,可以说隐式类型转换都是隐藏了()运算符的显式类型转换。例如:

int intValue = 10;
long longValue = (long)intValue; // 等价于 long longValue = intValue;

接下来,将分别研究数值类型、引用类型的显式类型转换,以及拆箱转换和显式类型转换的关系。


2.1 数值类型

在下列情况下,由于不存在自动的隐式转换,因此必须明确地进行显式类型转换:

  1. sbytebyteushortuintulongchar

  2. bytesbytechar

  3. shortsbytebyteushortuintulongchar

  4. ushortsbytebyteshortchar

  5. intsbytebyteshortushortuintulongchar

  6. uintsbytebyteshortushortintchar

  7. longsbytebyteshortushortintuintulongchar

  8. ulongsbytebyteshortushortintuintlongchar

  9. charsbytebyteshort

  10. floatsbytebyteshortushortintuintlongulongchardecimal

  11. doublesbytebyteshortushortintuintlongulongcharfloatdecimal

  12. decimalsbytebyteshortushortintuintlongulongcharfloatdouble

我们知道,隐式类型转换可以看作省略了()运算符的显式类型转换,因此对于数值类型间的转换来说,总是使用()运算符也没问题。

但是,显式的数值类型转换有可能造成信息丢失或者导致系统抛出异常,这也是系统为什么对于这种类型转换要求人工干预并且特别确认的原因。


2.2 溢出检查

当一种整型转换到另一种整型,这个过程取决于溢出检查上下文。checked关键字用于对整型算术运算和转换显式启用溢出检查,而unchecked关键字则用于取消整型算术运算和转换的溢出检查。

启用溢出检查

操作数的值在目标类型的取值范围内,则转换成功,否则将抛出一个System.OverflowException异常,如代码清单5-7所示。

代码清单5-7 使用checked上下文
using System;

namespace ProgrammingCSharp4
{
    class TypeConvert
    {
        static void Main(string[] args)
        {
            TypeConvert typeConvert = new TypeConvert();
            typeConvert.DoSomething();
        }

        public void DoSomething()
        {
            // MyInt的值为2147483647.
            try
            {
                int MyInt = int.MaxValue;
                byte MyByte = checked((byte)MyInt);
            }
            catch (OverflowException)
            {
                throw;
            }
        }
    }
}

在上述代码中,第18行中的int型变量MyInt的值为2,147,483,647,在第19行将MyInt强制转换为byte类型后,由于byte型的取值范围为0~255,因为这里使用了checked关键字启用了溢出检查,因此这里因为byte型无法容纳远大于其容量的数值而抛出System.OverflowException异常。

取消溢出检查

由于在转换过程将不检查数据是否超过目标类型的取值范围,意味着类型转换永远都会成功。如果源类型的取值范围大于目标类型,那么超过的部分将被截掉;如果源类型的取值范围小于目标类型,那么转换后将使用符号或者零填充至与目标类型的大小相等;如果等于则直接转换至目标类型,如代码清单5-8所示。

代码清单5-8 使用unchecked上下文
namespace ProgrammingCSharp4
{
    class TypeConvert
    {
        static void Main(string[] args)
        {
            TypeConvert typeConvert = new TypeConvert();
            typeConvert.DoSomething();
        }

        public void DoSomething()
        {
            // MyInt的值为2147483647.
            try
            {
                int MyInt = int.MaxValue;
                byte MyByte = (byte)MyInt;
            }
            catch (OverflowException)
            {
                throw;
            }
        }
    }
}

第17行并没有启用溢出检查,因此并没有抛出System.OverflowException异常,但转换的值也是有问题的。限于byte类型的取值范围,这里赋值后MyByte的值将为255,与原始值可以说大相径庭。第19行还可以使用unchecked关键字改写:

byte MyByte = unchecked((byte)MyInt);

2.3 引用类型

引用类型不同于值类型,它由两部分组成:栈中的变量和堆中的对象。对于引用类型的显式类型转换来说,转换的是栈中变量的类型,而该变量指向的位于堆中的对象则类型和数据都不受影响。一般来说,从基类向派生类的转换需要显式转换,因为基类“宽”而派生类“窄”,故而必须进行显式类型转换。

符合下列情况之一的,需要进行显式类型转换:

  1. object类型到任何引用类型的转换(任何引用类型都是object类型的子类);

  2. 基类到派生类的转换;

  3. 类到其实现的接口的转换;

  4. 非密封类到其没有实现接口的转换;

  5. 接口到另一个不是其基接口的转换;

  6. System.Array类型到数组类型的转换;

  7. System.Delegate类型到委托类型的转换。

显式类型转换的结果是否成功只有在运行时才能知道,转换失败则会抛出System.InvalidCastException异常。


2.4 拆箱

与装箱相反,从引用类型到值类型的转换称为拆箱。符合以下条件之一的进行拆箱操作:

  1. object类型或System.ValueType到值类型的转换;

  2. 从接口类型到值类型(实现了该接口)的转换;

  3. System.Enum类型到枚举类型的转换。

在执行拆箱操作前,编译器会首先检查引用类型是否是某个值类型或枚举类型的“装箱”版本,如果是就将其值拷贝出来,还原为值类型的变量。


3. as和is运算符

我们知道,隐式转换是安全的,而显式转换往往是不安全的,有可能造成精度损失,甚至会抛出异常。但类型转换又是不可避免的,例如对于某些集合类型,常常会用到System.Object类型的变量(使用泛型可以避免这种情况),对于非泛型集合,在将数据放入集合时将发生“向上转型”,即当前类型信息丢失,数据的类型成了object类型;而当需要把数据从集合取出时,因为需要恢复数据的本来类型,因此也就需要执行“向下转型”到它本来的类型。因此,如何更安全地进行类型转换就是一个值得探讨的问题了。幸好,C#已经为我们提供了解决方案,我们有两种选择:

  1. 使用as运算符进行类型转换;

  2. 先使用is运算符判断类型是否可以转换,再使用()运算符进行显式类型转换。

那么,我们先来介绍一下asis运算符:

as运算符用于在两个引用类型之间进行转换,如果转换失败则返回null,并不抛出异常,因此转换是否成功可以通过结果是否为null进行判断,并且只能在运行时才能判断。

代码示例
using System;

namespace ProgrammingCSharp4
{
    class Class1 { }
    class Class2 { }

    class TypeConvert
    {
        static void Main(string[] args)
        {
            object[] objArray = new object[6];
            objArray[0] = new Class1();
            objArray[1] = new Class2();
            objArray[2] = "hello";
            objArray[3] = 123;
            objArray[4] = 123.4;
            objArray[5] = null;

            for (int i = 0; i < objArray.Length; ++i)
            {
                string s = objArray[i] as string;
                Console.Write("{0}:", i);
                if (s != null)
                {
                    Console.WriteLine("是string类型,其值为:'" + s + "'");
                }
                else
                {
                    Console.WriteLine("不是string类型");
                }
            }
        }
    }
}

这段代码用到了前面讲过的知识:数组、Console对象、命名空间、类;也有如for循环、if判断等。

编译运行上述代码,输出结果为:

0:不是string类型
1:不是string类型
2:是string类型,其值为:'hello'
3:不是string类型
4:不是string类型
5:不是string类型

特别要注意的是,as运算符有一定的适用范围,它只适用于引用类型或可以为null的类型,而无法执行其他转换,如值类型的转换以及用户自定义的类型转换,这类转换应使用强制转换表达式来执行。

is运算符用于检查对象是否与给定类型兼容,并不执行真正的转换。如果判断的对象引用为null,则返回false。由于仅仅判断是否兼容,因此它并不会抛出异常。用法如下:

if (obj is MyObject)
{
    // 其他操作...
}

上述代码可以确定obj变量是否是MyObject类型的实例,或者是MyObject类的派生类。

同样,也要注意is的适用范围,它只适用于引用类型转换、装箱转换和拆箱转换。而不支持其他的类型转换,如值类型的转换。

现在,我们已经了解了asis运算符,在实际工作中建议尽量使用as运算符,而少使用()运算符显式转换。理由如下:

  1. 无论是as还是is运算符,都比直接使用()运算符强制转换更安全;

  2. 不会抛出异常,免除了使用try...catch进行异常捕获的必要和系统开销,只需要判断是否为null

  3. 使用as比使用is性能上更好,这一点可以通过代码清单5-9来说明。

代码清单5-9 as和is运算符的性能对比
using System;
using System.Diagnostics;

namespace ProgrammingCSharp4
{
    class Class1 { }

    class AsIsSample
    {
        private Class1 c1 = new Class1();

        public static void Main()
        {
            AsIsSample aiSample = new AsIsSample();
            Stopwatch timer = new Stopwatch();

            timer.Start();
            for (int i = 0; i < 10000; i++)
            {
                aiSample.DoSomething1();
            }
            timer.Stop();
            decimal micro = timer.Elapsed.Ticks / 10m;
            Console.WriteLine("执行DoSomething1() 10000次的时间:{0:F1} 微秒.", micro);

            timer = new Stopwatch();
            timer.Start();
            for (int i = 0; i < 10000; i++)
            {
                aiSample.DoSomething2();
            }
            timer.Stop();
            micro = timer.Elapsed.Ticks / 10m;
            Console.WriteLine("执行DoSomething2() 10000次的时间:{0:F1} 微秒.", micro);
        }

        public void DoSomething1()
        {
            object c2 = c1;
            if (c2 is Class1)
            {
                Class1 c = (Class1)c2;
            }
        }

        public void DoSomething2()
        {
            object c2 = c1;
            Class1 c = c2 as Class1;
            if (c != null)
            {
                // 其他操作...
            }
        }
    }
}

输出为:

执行DoSomething1() 10000次的时间:288.9 微秒.
执行DoSomething2() 10000次的时间:258.6 微秒.

从第37行开始,声明并定义了两个方法:DoSomething1DoSomething2,其中分别使用isas运算符进行类型转换。在第18行和第28行对每个方法分别连续调用10,000次,通过使用BCL中的Stopwatch对象对两者的调用时间进行统计。从结果可以看出,DoSomething2()的性能比DoSomething1()要好。至于原因,可以通过查看DoSomething1DoSomething2两个方法的CIL代码来一探究竟。方法DoSomething1的CIL代码如代码清单5-10所示。

代码清单5-10 方法DoSomething1的CIL代码
.method public hidebysig instance void DoSomething1() cil managed
{
    // Code size 34 (0x22)
    .maxstack 2
    .locals init ([0] object c2, [1] class ProgrammingCSharp4.Class1 c, [2] bool CS$4$0000)
    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: ldfld class ProgrammingCSharp4.Class1 ProgrammingCSharp4.AsIsSample::c1
    IL_0007: stloc.0
    IL_0008: ldloc.0
    IL_0009: isinst ProgrammingCSharp4.Class1
    IL_000e: ldnull
    IL_000f: cgt.un
    IL_0011: ldc.i4.0
    IL_0012: ceq
    IL_0014: stloc.2
    IL_0015: ldloc.2
    IL_0016: brtrue.s IL_0021
    IL_0018: nop
    IL_0019: ldloc.0
    IL_001a: castclass ProgrammingCSharp4.Class1
    IL_001f: stloc.1
    IL_0020: nop
    IL_0021: ret
} // end of method ProgrammingCSharp4.AsIsSample::DoSomething1

代码清单5-10的第13行首先测试了是否能转换到Class1类型,如果可以则进行转换;第23行再次测试能否转换到Class1类型,如果测试成功则进行转换。

方法DoSomething2的CIL代码如代码清单5-11所示。

代码清单5-11 方法DoSomething2的CIL代码
.method public hidebysig instance void DoSomething2() cil managed
{
    // Code size 26 (0x1a)
    .maxstack 2
    .locals init ([0] object c2, [1] class ProgrammingCSharp4.Class1 c, [2] bool CS$4$0000)
    nop
    ldarg.0
    ldfld class ProgrammingCSharp4.Class1 ProgrammingCSharp4.AsIsSample::c1
    stloc.0
    ldloc.0
    isinst ProgrammingCSharp4.Class1
    stloc.1
    ldloc.1
    ldnull
    ceq
    stloc.2
    ldloc.2
    brtrue.s IL_0019
    nop
    nop
    ret
} // end of method ProgrammingCSharp4.AsIsSample::DoSomething2

代码清单5-11的第11行同样测试了能否转换到Class1类型,如果可以则进行转换。

由此可见,前者进行了两次测试和检查,而后者只进行了一次测试,这是造成两者之间性能差异的原因。

现在我们总结下,什么场合该使用is,什么场合该使用as:如果测试对象的目的是确定它是否属于所需类型,并且如果测试结果为真,就要立即进行转换,这种情况下使用as操作符的效率更高;但有时仅仅只是测试,并不想立即转换,也可能根本就不会转换,只是在对象实现了接口时,要将它加到一个列表中,这时is操作符就是一种更好的选择。


网站公告

今日签到

点亮在社区的每一天
去签到