C#高级编程笔记-运算符和类型强制转换

本章将首先讨论基本语言元素,接着论述C#语言的扩展功能。本章的主要内容如下:

●       C#中的运算符

●       使用nameof运算符和空值条件运算符

●       隐式和显式转换

●       使用装箱技术把值类型转换为引用类型

●       比较值类型和引用类型

●      实现索引运算符

●       重载标准的运算符,以支持对定制类型的操作

●       通过类型强制转换在引用类型之间转换

目录

1.1  运算符

1.1.1 运算符的简化操作

1.1.2  条件运算符

1.1.3  checked和unchecked运算符

1.1.4  is运算符

1.1.5  as运算符

1.1.6  sizeof运算符

1.1.7  typeof运算符

1.1.8 nameof运算符

1.1.9 index运算符

1.1.10  可空类型和运算符

1.1.11  空合并运算符

1.1.12 空值条件运算符

1.1.13  运算符的优先级

1.2 使用二进制运算符

1.2.1 位的移动

1.3 类型安全性

1.3.1 类型转换

1. 隐式转换方式

2. 显式转换方式

1.3.2  装箱和拆箱

1.4  对象的相等比较

1.4.1  引用类型的相等比较

1. ReferenceEquals()方法

2. 虚拟的Equals()方法

3. 静态的Equals()方法

4. 比较运算符==

1.4.2  值类型的相等比较

1.5  运算符重载

1.5.1  运算符的工作方式

1.5.2  运算符重载的示例:Vector结构

1.5.3 可以重载的运算符

1.6 实现自定义的索引运算符

1.7 用户定义的类型强制转换

1.7.1  执行用户定义的类型转换

1. 类之间的数据类型转换

2. 基类和派生类之间的数据类型转换

3. 装箱和拆箱数据类型转换

1.7.2  多重数据类型转换


1.1  运算符

注意:

有4个运算符(sizeof、*、–>、&)只能用于不安全的代码(这些代码绕过了C#类型安全性的检查)

1.1.1 运算符的简化操作

为什么用两个例子来说明++增量和– –减量运算符?把运算符放在表达式的前面称为前置,把运算符放在表达式的后面称为后置。它们的执行方式有所不同。

增量或减量运算符可以作用于整个表达式,也可以作用于表达式的内部。当x++和++x单独占一行时,它们的作用是相同的,对应于语句x = x + 1。但当它们用于表达式内部时,把运算符放在前面(++x)会在计算表达式之前递增x,换言之,递增了x后,在表达式中使用新值进行计算。而把运算符放在后面(x++)会在计算表达式之后递增x—— 使用x的原值计算表达式。下面的例子使用++增量运算符说明了它们的区别:

第一个if条件得到true,因为在计算表达式之前,x从5递增为6。第二个if语句中的条件为false,因为在计算完整个表达式(x=6)后,x才递增为7。

前置运算符– –x和后置运算符x– –与此类似,但它们是递减,而不是递增。

1.1.2  条件运算符

条件运算符(?:)也称为三元运算符,是if...else结构的简化形式。其名称的出处是它带有三个操作数。它可以计算一个条件,如果条件为真,就返回一个值;如果条件为假,则返回另一个值。其语法如下:

condition ? true_value : false_value

其中condition是要计算的Boolean型表达式,true_value是condition为true时返回的值,false_value是condition为false时返回的值。

恰当地使用三元运算符,可以使程序非常简洁。它特别适合于给被调用的函数提供两个参数中的一个。使用它可以把Boolean值转换为字符串值true或false。它也很适合于显示正确的单数形式或复数形式,例如:

如果x等于1,这段代码就显示1 man,如果x等于其他数,就显示其正确的复数形式。但要注意,如果结果需要用在不同的语言中,就必须编写更复杂的例程,以考虑到不同语言的不同语法。

1.1.3  checked和unchecked运算符

考虑下面的代码:

byte数据类型只能包含0~255的数,给byte.MaxValue分配一个字节,得到255。对于255,字节中所有可用的8个位都得到设置:11111111。所以递增这个值会导致溢出,得到0。

CLR如何处理这个溢出取决于许多方面,包括编译器选项,所以只要有未预料到的溢出风险,就需要用某种方式确保得到我们希望的结果。

为此,C#提供了checked和unchecked运算符。如果把一个代码块标记为checked,CLR就会执行溢出检查,如果发生溢出,就抛出异常。下面修改代码,使之包含checked运算符:

运行这段代码,就会得到一条错误信息:

使用Advance Build Settings中的Visual Studio项目设置Check for Arithmetic Overflow/Underflow,可以对所有未标记的代码进行溢出检查。也可以直接在项目文件中改变它:

如果要禁止溢出检查,可以把代码标记为unchecked:

在本例中,不会抛出异常,但会丢失数据——因为byte数据类型不能包含256,溢出的位会被丢掉,所以b变量得到的值是0。

注意,unchecked是默认值。只有在需要把几行未检查的代码行放在一个明确标记为checked的大代码块中,才需要显式使用unchecked关键字。

注意:

默认不检查上溢出和下溢出,因为执行检查会影响性能。使用checked作为默认设置时,每一个算术运算的结果都需要验证其值是否越界。算术运算也可以用于使用i++的for循环中。为了避免这种性能影响,最好一直不使用默认设置(Check for Arithmetic Overflow/Underflow),在需要时使用checked运算符。

1.1.4  is运算符

is运算符可以检查对象是否与特定的类型兼容。“兼容”表示对象是该类型,或者派生于该类型。例如,要检查变量是否与object类型兼容,可以使用下面的代码:

int和其他C#数据类型一样,也从object继承而来;表达式i is object将得到true,并显示相应的信息。

C#7扩展了具有类型匹配的is运算符。可以检查常量、类型和var。下面的代码片段显示了常量检查的示例,它检查常量42和常量null:

使用具有类型匹配的is运算符,可以在类型的右边声明变量。如果is运算符返回true,则该变量通过对该类型的对象的引用来填充。然后,可以在使用is运算符的if语句范围内使用该变量:

1.1.5  as运算符

as运算符用于执行引用类型的显式类型转换。如果要转换的类型与指定的类型兼容,转换就会成功进行;如果类型不兼容,as运算符就会返回值null。如下面的代码所示,如果object引用不指向string实例,把object引用转换为string就会返回null:

as运算符允许在一步中进行安全的类型转换,不需要先使用is运算符测试类型,再执行转换。

1.1.6  sizeof运算符

使用sizeof运算符可以确定堆栈中值类型需要的长度(单位是字节):

Console.WriteLine(sizeof(int));

其结果是显示数字4,因为int有4个字节。

如果结构体只包含值类型,也可以使用sizeof运算符和结构一如下所示的Point类

注意:类不能使用sizeof运算符。

如果对复杂类型(而非基本类型使用sizeof运算符,就需要把代码放在nsafe块中,如下所示:

默认情况下不允许使用不安全的代码,需要在csproj项目文件中指定AllowUnsafeBlocks。

1.1.7  typeof运算符

typeof运算符返回一个表示特定类型的System.Type对象。例如,typeof(string)返回表示System.String类型的Type对象。在使用反射技术动态查找对象的信息时,这个运算符是很有效的。在后面的文章中将介绍反射。

1.1.8 nameof运算符

nameof是新的C#6运算符。该运算符接受一个符号、属性或方法,并返回其名称。

这个运算符如何使用?一个例子是需要一个变量的名称时,如检查参数是否为null:

当然,这类似于传递一个字符串而不是使用nameof运算符来抛出异常。然而,如果名称拼错,传递字符串并不会显示一个编译器错误。另外,改变参数的名称时,就很容易忘记更改传递到ArgumentNullException构造函数的字符串。

对变量的名称使用nameof运算符只是一个用例。还可以使用它得到属性的名称,例如,在属性set访问器中触发改变事件(使用INotifyPropertyChanged接口),并传递属性的名称。

nameof运算符也可以用来得到方法的名称。如果方法是重载的,它同样适用,因为所有的重载版本都得到相同的值:方法的名称。

1.1.9 index运算符

在下面的“数组”中将使用索引运算符(括号)访问数组。这里传递数值2,使用索引运算符访问数组arr1的第三个元素:

类似于访问数组元素,索引运算符用集合类实现。

索引运算符不需要把整数放在括号内,并且可以用任何类型定义。下面的代码片段创建了一个泛型字典,其键是一个字符串,值是一个整数。在字典中,键可以与索引器一起使用。在下面的示例中,字符串t传递给索引运算符,以设置字典里的这个元素,然后把相同的字符串传递给索引器来检索此元素:

1.1.10  可空类型和运算符

值类型和引用类型的一个重要区别是,引用类型可以为空。值类型(如int)不能为空。把C#类型映射到数据库类型时,这是一个特殊的问题。数据库中的数值可以为空。在早期的C版本中,一个解决方案是使用引用类型来映射可空的数据库数值。然而,这种方法会影响性能,因为垃圾收集器需要处理引用类型。现在可以使用可空的int来替代正常的int。其开销只是使用一个额外的布尔值来检查或设置空值。可空类型仍然是值类型。

在下面的代码片段中,变量i1是一个int,并给它分配1,i2是一个可空的int,给它分配i1。可空性使用?和类型来定义。给int?分配整数值的方式类似于i1的分配。变量i3表明,也可以给可空类型分配null

每个结构都可以定义为可空类型,如下面的long?和DateTime?所示:

如果在程序中使用可空类型,就必须考虑null值在与各种运算符一起使用时的影响。通常可空类型与一元或二元运算符一起使用时,如果其中一个操作数或两个操作数都是null,其结果就是null。例如:

但是在比较可空类型时,只要有一个操作数是null,比较的结果就是false。即不能因为一个条件是false,就认为该条件的对立面是true,这在使用非可空类型的程序中很常见。例如,在下面的例子中,如果a是空,则无论b的值是+5还是-5,总是会调用else子句:

null值的可能性表示,不能随意合并表达式中的可空类型和非可空类型,详见本章后面的内容。

注意:使用C#关键字?和类型声明时,例如int?,编译器会解析它,以使用泛型类型Nullable<int>。C#编译器把速记符号转换为泛型类型,来减少输入量。

1.1.11  空合并运算符

空接合运算符(??)提供了一种快捷方式,可以在处理可空类型和引用类型时表示Null值。这个运算符放在两个操作数之间,第一个操作数必须是一个可空类型或引用类型,第二个操作数必须与第一个操作数的类型相同,或者可以隐含地转换为第一个操作数的类型。空接合运算符的计算如下:如果第一个操作数不是null,则整个表达式就等于第一个操作数的值。但如果第一个操作数是null,则整个表达式就等于第二个操作数的值。例如:


如果第二个操作数不能隐含地转换为第一个操作数的类型,就生成一个编译错误。

空合并运算符不仅对可空类型很重要,对引用类型也很重要。在下面的代码片段中,属性Val只有在不为空时才返回_val变量的值。如果它为空,就创建MyClass的一个新实例,分配给val变量,最后从属性中返回。只有在变量_val为空时,才执行get访问器中表达式的第二部分。

1.1.12 空值条件运算符

C#中减少大量代码行的一个功能是空值条件运算符。生产环境中的大量代码行都会验证空值条件。访问作为方法参数传递的成员变量之前,需要检查它,以确定该变量的值是否为null,否则会抛出一个 NullReferenceException异常。.NET设计准则指定,代码不应该抛出这些类型的异常,应该检查空值条件。然而,很容易忘记这样的检查。下面的这个代码片段验证传递的参数p是否非空。如果它为空,方法就只是返回,而不会继续执行:

使用空值条件运算符访问FirstName属性(p?.FirstName),当p为空时,就只返回null,而不继续执行表达式的右侧。

使用空值条件运算符访问int类型的属性时,不能把结果直接分配给int类型,因为结果可以为空。解决这个问题的一种选择是把结果分配给可空的int:

int ? age = p?.Age;

当然,要解决这个问题,也可以使用空合并运算符,定义另一个结果(例如0),以防止左边的结果为空:

int age1 = p?.Age ?? 0;

也可以结合多个空值条件运算符。下面访问Person对象的Address属性,这个属性又定义了City属性,Person对象需要进行null检查,如果它不为空,Address属性的结果也不为空:

使用空值条件运算符时,代码会更简单:

string city = p?.HomeAddress?.City;

还可以把空值条件运算符用于数组。在下面的代码片段中,使用索引运算符访问值为null的数组变量元素时,会抛出NullReferenceException异常:

当然,可以进行传统的null检查,以避免这个异常条件。更简单的版本是使用?[0]访问数组中的第一个元素。如果结果是null,空合并运算符就返回x1变量的值:

1.1.13  运算符的优先级

表6-3显示了C#运算符的优先级。表顶部的运算符有最高的优先级(即在包含多个运算符的表达式中,最先计算该运算符):

在复杂的表达式中,应避免利用运算符优先级来生成正确的结果。使用括号指定运算符的执行顺序,可以使代码更整洁,避免出现潜在的冲突。

1.2 使用二进制运算符

在学习编程时,使用二进制值一直是一个需要理解的重要概念,因为计算机使用0和1。现在,许多人可能已经错过了它的学习,因为他们是使用Blocks、Scratch,甚至可能是使用JavaScript开始学习编程的。如果用户己经很了解0和1,本节仍然可以帮助复习。

首先,从使用二进制运算符的简单计算开始。方法SimpleCalculations首先使用二进制值(二进制字面量和数字分隔符)声明并初始化变量binaryl和binary.2。使用&运算符,两个值用二进制ADD运算符合并起来,并写入变量binaryAnd。.然后,使用运算符|创建binaryOr变量,使用运算符^创建binaryXOR变量,使用运算符~创建reversel变量。

要以二进制形式显示uint和int变量,需要创建扩展方法ToBinaryString。Convert.ToString提供的一个重载带有两个int参数,其中第二个int值是toBase参数。使用这个方法,可以通过传递值2、八进制(8)、十进制(10)和十六进制(16来格式化输出字符串binary。默认情况下,如果二进制值以0开始,这些0值将被忽略,而不会打印出来。PadLeft方法确保结果字符串的长度至少为sizeof(uint) << 3,即32位,这是uint类型位数的两倍(因为每个二进制位用一个字符表示)。如果原始二进制字符串短于这个长度,它将在左侧填充0。另一个扩展方法是AddSeparators,它使用LINQ方法在每四位数之后添加分隔符。

方法DisplayBits是从前面显示的SimpleCalculations方法调用的,它使用ToBinaryString和AddSeparators扩展方法。在这里,将显示用于操作的操作数,以及结果:

在运行应用程序时,可以看到使用二进制运算符&的以下输出。对于这个运算符,只有两个输入值都为1时,得到的位才是1

应用二进制运算符 |,如果有一个为1,则都为1:

对于^运算符,如果两个原始的位只设置了一个,而没有设置两个,就设置结果:

最后,对于运算符~,结果是对原始位的否定:

1.2.1 位的移动

如前面的示例所述,向左移动3位就是原来的数字乘以8。向左移动1位就是原来的数字乘以2。这比调用乘法运算符要快得多一假定需要乘以2、4、8、16、32等。

下面的代码片段在变量s1中设置了一个位,在0r循环中,这个位总是移动一位:

在程序的输出中,可以看到循环中的二进制、十进制和十六进制值:

1.2.2 有符号数和无符号数

使用二进制时要记住的一件重要的事情是,使用带符号的类型时,如int、long、short,最左端的一位用来表示符号。使用int类型时,可用的最大值是2147483647一31位的正数或0x7 FFF FFFF。对于uint,可用的最大值是4294967295或0 xFFFF FFFF。这表示32位的正数。对于int,数字范围的另一半用于负数。

1.3 类型安全性

C#高级编程笔记-.Net体系结构提到中间语言(IL)可以对其代码强制加上强类型安全性。强类型支持.NET提供的许多服务,包括安全性和语言的交互性。因为C#这种语言会编译为IL,所以C#也是强类型的。这说明数据类型并不总是可互换的。本节将介绍基本类型之间的转换。

1.3.1 类型转换

我们常常需要把数据从一种类型转换为另一种类型。考虑下面的代码:

在编译这些代码时,会产生一个错误:

Cannot implicitly convert type 'int' to 'byte' (不能把int类型隐式地转换为byte类型)。

问题是,我们把两个byte型数据加在一起时,应返回int型结果,而不是另一个byte。这是因为byte包含的数据只能为8位,所以把两个byte型数据加在一起,很容易得到不能存储在byte变量中的值。如果要把结果存储在一个byte变量中,就必须把它转换回byte。C#有两种转换方式:隐式转换方式和显式转换方式。

1. 隐式转换方式

只要能保证值不会发生任何变化,类型转换就可以自动进行。这就是前面代码失败的原因:试图从int转换为byte,而潜在地丢失了3个字节的数据。编译器不会告诉我们该怎么做,除非我们明确告诉它这就是我们希望的!如果在long型变量中存储结果,而不是byte型变量中,就不会有问题了:

这是因为long类型变量包含的数据字节比byte类型多,所以数据没有丢失的危险。在这些情况下,编译器会很顺利地转换,我们也不需要显式提出要求。
表6-4介绍了C#支持的隐式类型转换。

注意,只能从较小的整数类型隐式地转换为较大的整数类型,不能从较大的整数类型隐式地转换为较小的整数类型。也可以在整数和浮点数之间转换,其规则略有不同,可以在相同大小的类型之间转换,例如int/uint转换为 float,long/ulong转换为double,也可以从long/ulong转换回float。这样做可能会丢失4个字节的数据,但这仅表示得到的float值比使用double得到的值精度低,编译器认为这是一种可以接受的错误,而其值的大小是不会受到影响的。无符号的变量可以转换为有符号的变量,只要无符号的变量值的大小在有符号的变量的范围之内即可。

在隐式转换值类型时,可空类型需要额外考虑:

●       可空类型隐式转换为其他可空类型,应遵循表6-4中非可空类型的转换规则。即int? 隐式转换为long?、float?、double?和decimal?。

●       非可空类型隐式转换为可空类型也遵循表6-4中的转换规则,即int隐式转换为long?、float?、double?和decimal?。

●       可空类型不能隐式转换为非可空类型,此时必须进行显式转换,如下一节所述。这是因为可空类型的值可以是null,但非可空类型不能表示这个值。

2. 显式转换方式

有许多场合不能隐式地转换类型,否则编译器会报告错误。但是,可以使用cast显式执行这些转换。在把一种类型强制转换为另一种类型时,要迫使编译器进行转换。只需要把转换的目标类型名放在要转换的值之前的圆括号中。

这种类型转换是一种比较危险的操作,即使在从long转换为int这样简单的转换过程中,如果原来long的值比int的最大值还大,就会出问题:

long val = 3000000000;

int i = (int)val;         // An invalid cast. The maximum int is 2147483647

在本例中,不会报告错误,也得不到期望的结果。如果运行上面的代码,结果存储在i中,则其值为:

–1294967296

最好假定显式数据转换不会给出希望的结果。如前所述,C#提供了一个checked运算符,使用它可以测试操作是否会产生算术溢出。使用这个运算符可以检查数据类型转换是否安全,如果不安全,就会让运行库抛出一个溢出异常:

long val = 3000000000;

int i = checked ((int)val);

记住,所有的显式数据类型转换都可能不安全,在应用程序中应包含这样的代码,处理可能失败的数据类型转换。第14章将使用try和 catch语句引入结构化异常处理。

使用数据类型转换可以把大多数数据从一种基本类型转换为另一种基本类型。例如:给price加上0.5,再把结果转换为int:

double price = 25.30;

int approximatePrice = (int)(price + 0.5);

这么做的代价是把价格四舍五入为最接近的美元数。但在这个转换过程中,小数点后面的所有数据都会丢失。因此,如果要使用这个修改过的价格进行更多的计算,最好不要使用这种转换;如果要输出全部计算完或部分计算完的近似值,且不希望用小数点后面的数据去麻烦用户,这种转换是很好的。

下面的例子说明了把一个无符号的整数转换为char型时,会发生的情况:

ushort c = 43;

char symbol = (char)c;

Console.WriteLine(symbol);

结果是ASCII编码为43的字符,即+号。可以尝试数字类型之间的任何转换(包括char),这种转换是成功的,例如把decimal转换为char,或把char转换为decimal。


1.3.2  装箱和拆箱

第2章介绍了所有类型,包括简单的预定义类型,例如int和char,以及复杂类型,例如从Object类型中派生的类和结构。下面可以像处理对象那样处理字面值:

string s = 10.ToString();

但是,C#数据类型可以分为在堆栈上分配内存的值类型和在堆上分配内存的引用类型。如果int不过是堆栈上一个4字节的值,该如何在它上面调用方法?

C#的实现方式是通过一个有点魔术性的方式,即装箱(boxing)装箱和拆箱(unboxing)可以把值类型转换为引用类型,或把引用类型转换为值类型。这已经在数据类型转换一节中介绍过了,即把值转换为object类型。装箱用于描述把一个值类型转换为引用类型。运行库会为堆上的对象创建一个临时的引用类型“箱子”。该转换是隐式进行的。

拆箱用于描述相反的过程,即以前装箱的值类型转换回值类型。这里使用术语“cast(强制转换)”,是因为这种数据类型转换是显式进行的。其语法类似于前面的显式类型转换:


只能把以前装箱的变量再转换为值类型。当myObject不是装箱后的int型时,如果执行上面的代码,就会在运行期间抛出一个异常。

这里有一个警告。在拆箱时,必须非常小心,确保得到的值变量有足够的空间存储拆箱的值中的所有字节。例如,C#的int只有32位,所以把long值(64位)拆箱为int时,会产生一个InvalidCastException异常:

1.4  对象的相等比较

在讨论了运算符,并简要介绍了相等运算符后,就应考虑在处理类和结构的实例时,“相等”意味着什么。理解对象相等比较的机制对编写逻辑表达式非常重要,另外,对实现运算符重载和数据类型转换也非常重要,本章的后面将讨论运算符重载。

对象相等比较的机制对于引用类型(类的实例)的比较和值类型(基本数据类型,结构或枚举的实例)的比较来说是不同的。下面分别介绍引用类型和值类型的相等比较。

1.4.1  引用类型的相等比较

System.Object定义了3个不同的方法,来比较对象的相等性:ReferenceEquals()和Equals()的两个版本。再加上比较运算符==,实际上有4种进行相等比较的方式。这些方法有一些微妙的区别,下面就介绍这些方法。

1. ReferenceEquals()方法

ReferenceEquals()是一个静态方法,测试两个引用是否指向类的同一个实例,即两个引用是否包含内存中的相同地址。作为静态方法,它不能重写,所以只能使用System.Object的实现代码。如果提供的两个引用指向同一个对象实例,ReferenceEquals()总是返回true,否则就返回false。但是它认为null等于null:

SomeClass x, y;

x = new SomeClass();

y = new SomeClass();

z = x;

bool B1 = ReferenceEquals(null, null);      //return true

bool B2 = ReferenceEquals(null, x);          //return false

bool B3 = ReferenceEquals(x, y); //return false because x and y references different objects

bool B4 =  ReferenceEquals(x,z); ///return true

2. 虚拟的Equals()方法

Equals()虚拟版本的System.Object实现代码也可以比较引用。但因为这个方法是虚拟的,所以可以在自己的类中重写它,按值来比较对象。特别是如果希望类的实例用作字典中的键,就需要重写这个方法,以比较值。否则,根据重写Object.GetHashCode()的方式,包含对象的字典类要么不工作,要么工作的效率非常低。在重写Equals()方法时要注意,重写的代码不会抛出异常。这是因为如果抛出异常,字典类就会出问题,一些在内部调用这个方法的.NET基类也可能出问题。

3. 静态的Equals()方法

Equals()的静态版本与其虚拟实例版本的作用相同,其区别是静态版本带有两个参数,并对它们进行相等比较。这个方法可以处理两个对象中有一个是null的情况,因此,如果一个对象可能是null,这个方法就可以抛出异常,提供额外的保护。静态重载版本首先要检查它传送的引用是否为null。如果它们都是null,就返回true(因为null与null相等)。如果只有一个引用是null,就返回false。如果两个引用都指向某个对象,它就调用Equals()的虚拟实例版本。这表示在重写Equals()的实例版本时,其效果相当于也重写了静态版本。

4. 比较运算符==

最好将比较运算符看作是严格的值比较和严格的引用比较之间的中间选项。在大多数情况下,下面的代码表示比较引用:

bool b = (x == y);          //x, y object references

但是,如果把一些类看作值,其含义就会比较直观。在这些情况下,最好重写比较运算符,以执行值的比较。后面将讨论运算符的重载,但显然它的一个例子是System.String类,Microsoft重写了这个运算符,比较字符串的内容,而不是它们的引用。


1.4.2  值类型的相等比较

在进行值类型的相等比较时,采用与引用类型相同的规则:ReferenceEquals()用于比较引用,Equals()用于比较值,比较运算符可以看作是一个中间项。但最大的区别是值类型需要装箱,才能把它们转换为引用,才能对它们执行方法。另外,Microsoft已经在System.ValueType类中重载了实例方法Equals(),以便对值类型进行合适的相等测试。如果调用sA.Equals(sB),其中sA和sB是某个结构的实例,则根据sA和sB是否在其所有的字段中包含相同的值,而返回true或false。另一方面,在默认情况下,不能对自己的结构重载==运算符。在表达式中使用(sA==sB)会导致一个编译错误,除非在代码中为结构提供了==的重载版本。

另外,ReferenceEquals()在应用于值类型时,总是返回false,因为为了调用这个方法,值类型需要装箱到对象中。即使使用下面的代码:

bool b = ReferenceEquals(v, v);          //v is a variable of some value type

也会返回false,因为在转换每个参数时,v都会被单独装箱,这意味着会得到不同的引用。调用ReferenceEquals()来比较值类型实际上没有什么意义。
尽管System.ValueType提供的Equals()的默认重写代码肯定足以应付绝大多数自定义的结构,但仍可以为自己的结构重写它,以提高性能。另外,如果值类型包含作为字段的引用类型,就需要重写Equals(),以便为这些字段提供合适的语义,因为Equals()的默认重写版本仅比较它们的地址。

1.5  运算符重载

本节将介绍为类或结构定义的另一种类型的成员:运算符重载。

运算符重载的关键是在类实例上不能总是调用方法或属性,有时还需要做一些其他的工作,例如对数值进行相加、相乘或逻辑操作,如比较对象等。假定要定义一个类,表示一个数学矩阵,在数学中,矩阵可以相加和相乘,就像数字一样。所以可以编写下面的代码:

通过重载运算符,就可以告诉编译器,+和*对Matrix对象进行什么操作,以编写上面的代码。如果用不支持运算符重载的语言编写代码,就必须定义一个方法,以执行这些操作,结果肯定不太直观,如下所示。
Matrix d = c.Multiply(a.Add(b));

学习到现在,像+和*这样的运算符只能用于预定义的数据类型,原因很简单:编译器认为所有常见的运算符都是用于这些数据类型的,例如,它知道如何把两个long加起来,或者如何从一个double中减去另一个double,并生成合适的中间语言代码。但在定义自己的类或结构时,必须告诉编译器:什么方法可以调用,每个实例存储了什么字段等所有的信息。同样,如果要在自己的类上使用运算符,就必须告诉编译器相关的运算符在这个类中的含义。此时就要定义运算符重载。

要强调的另一个问题是重载不仅仅限于算术运算符。还需要考虑比较运算符 ==、<、>、!=、>=和<=。例如,语句if(a==b)。对于类,这个语句在默认状态下会比较引用a和b,检测这两个引用是否指向内存中的同一个地址,而不是检测两个实例是否包含相同的数据。对于string类,这种操作就会重写,比较字符串实际上就是比较每个字符串的内容。可以对自己的类进行这样的操作。对于结构,==运算符在默认状态下不做任何工作。试图比较两个结构,看看它们是否相等,就会产生一个编译错误,除非显式重载了==,告诉编译器如何进行比较。

在许多情况下,重载运算符允许生成可读性更高、更直观的代码,包括:

●       在数学领域中,几乎包括所有的数学对象:坐标、矢量、矩阵、张量和函数等。如果编写一个程序执行某些数学或物理建模,肯定会用类表示这些对象。

●       图形程序在计算屏幕上的位置时,也使用数学或相关的坐标对象。

●       表示大量金钱的类(例如,在财务程序中)。

●       字处理或文本分析程序也有表示语句、子句等的类,可以使用运算符把语句连接在一起(这是字符串连接的一种比较复杂的版本)。

另外,有许多类与运算符重载并不相关。不恰当地使用运算符重载,会使使用类型的代码很难理解。例如,把两个DateTime对象相乘,在概念上没有任何意义.

1.5.1  运算符的工作方式

为了理解运算符是如何重载的,考虑一下在编译器遇到运算符时会发生什么样的情况是很有用的——我们用相加运算符+作为例子来讲解。假定编译器遇到下面的代码:

考虑当编译器遇到下面这行代码时会发生什么情况:

long myLong = myInteger + myUnsignedInt;

编译器知道它需要把两个整数加起来,并把结果赋予long。调用一个方法把数字加在一起时,表达式myInteger + myUnsignedInt是一种非常直观、方便的语法。该方法带有两个参数myInteger和myUnsignedInt,并返回它们的和。所以编译器完成的任务与任何方法调用是一样的—— 它会根据参数类型查找最匹配的+运算符重载,这里是带两个整数参数的+运算符重载。与一般的重载方法一样,预定义的返回类型不会因为调用的方法版本而影响编译器的选择。在本例中调用的重载方法带两个int类型参数,返回一个int,这个返回值随后会转换为long。

下一行代码让编译器使用+运算符的另一个重载版本:

double myOtherDouble = myDouble + myInteger;

在这个例子中,参数是一个double类型的数据和一个int类型的数据,但+运算符没有带这种复合参数的重载形式,所以编译器认为,最匹配的+运算符重载是把两个double作为其参数的版本,并隐式地把int转换为double。把两个double加在一起与把两个整数加在一起完全不同,浮点数存储为一个尾数和一个指数。把它们加在一起要按位移动一个double的尾数,让两个指数有相同的值,然后把尾数加起来,移动所得尾数的位,调整其指数,保证答案有尽可能高的精度。

现在,看看如果编译器遇到下面的代码,会发生什么:

其中,Vector是结构,稍后再定义它。编译器知道它需要把两个Vector实例加起来,即vect1 和 vect2。它会查找+运算符的重载,把两个Vector实例作为参数。

如果编译器找到这样的重载版本,就调用它的实现代码。如果找不到,就要看看有没有可以用作最佳匹配的其他+运算符重载,例如某个运算符重载的参数是其他数据类型,但可以隐式地转换为Vector实例。如果编译器找不到合适的运算符重载,就会产生一个编译错误,就像找不到其他方法调用的合适重载版本一样。

1.5.2  运算符重载的示例:Vector结构

本节将开发一个结构Vector,来演示运算符重载,这个Vector结构表示一个三维矢量。

矢量可以与矢量或数字相加或相乘。在这里我们使用术语“标量”(scalar),它是数字的数学用语—— 在C#中,就是一个double。相加的作用是很明显的。如果先移动(3.0, 3.0, 1.0),再移动(2.0, –4.0, –4.0),总移动量就是把这两个矢量加起来。矢量的相加是指把每个元素分别相加,因此得到(5.0, –1.0,–3.0)。此时,数学表达式总是写成c=a+b,其中a和b是矢量,c是结果矢量。这与使用Vector结构的方式是一样的。

注意:

这个例子是作为一个结构来开发的,而不是类,但这并不重要。运算符重载用于结构和类时,其工作方式是一样的。

下面是Vector的定义—— 包含成员字段、构造函数和一个ToString()重写方法,以便查看Vector的内容,最后是运算符重载:

这里提供了两个构造函数,通过传递每个元素的值,或者提供另一个复制其值的Vector,来指定矢量的初始值。第二个构造函数带一个Vector参数,通常称为复制构造函数,因为它们允许通过复制另一个实例来初始化一个类或结构实例。注意,为了简单起见,把字段设置为public。也可以把它们设置为private,编写相应的属性来访问它们,这样做不会改变这个程序的功能,只是代码会复杂一些。
下面是Vector结构的有趣部分—— 为+运算符提供支持的运算符重载:

运算符重载的声明方式与方法的声明方式相同,但operator关键字告诉编译器,它实际上是一个运算符重载,后面是相关运算符的符号,在本例中就是+。返回类型是在使用这个运算符时获得的类型。在本例中,把两个矢量加起来会得到另一个矢量,所以返回类型就是Vector。对于这个+运算符重载,返回类型与包含类一样,但这种情况并不是必需的。两个参数就是要操作的对象。对于二元运算符(带两个参数),如+和-运算符,第一个参数是放在运算符左边的值,第二个参数是放在运算符右边的值。

这个实现代码返回一个新的矢量,该矢量用left和ight变量的X、Y和Z属性初始化。

C#要求所有的运算符重载都声明为public和static,这表示它们与它们的类或结构相关联,而不是与实例相关联,所以运算符重载的代码体不能访问非静态类成员,也不能访问this标识符;这是可以的,因为参数提供了运算符执行其任务所需要知道的所有输入数据。

测试代码:

 运行结果:

注意:

与C++不同,C#不允许重载=运算符

1.5.3 可以重载的运算符

并不是所有的运算符都可以重载。可以重载的运算符如表6-5所示。

为什么要重载true和false操作符?有一个很好的原因:根据所使用的技术或框架,哪些整数值代表true或false是不同的。在许多技术中,0是false,1是true;其他技术把非0值定义为true,还有一些技术把-1定义为false。

1.6 实现自定义的索引运算符

自定义索引器不能使用运算符重载语法来实现,但是它们可以用与属性非常相似的语法来实现。

首先看看数组元素的访问。这里创建一个int元素数组。第二行代码使用索引器来访问第二个元素,并给它传递42。第三行使用索引器来访问第三个元素,并给该元素传递变量x。

要创建自定义索引器,首先要创建一个Person类,其中包含FirstName、LastName和Birthday只读属性

类PersonCollection定义了一个包含Person元素的私有数组字段,以及一个可以传递许多Person对象的构造函数。

为了允许使用索引器语法访问PersonCollection并返回Person对象,可以创建一个索引器。索引器看起来非常类似于属性,因为它也包含get和set访问器。两者的不同之处是名称。指定索引器要使用this关键字。this关键字后面的括号指定索引使用的类型。数组提供int类型的索引器,所以这里使用int类型直接把信息传递给被包含的数组people。get和set访问器的使用非常类似于属性。检索值时调用get访问器,在右边传递Person对象时调用set访问器。

对于索引器,不能仅定义int类型作为索引类型。任何类型都是有效的,如下面的代码所示,其中把DateTime结构作为索引类型。这个索引器用来返回有指定生日的每个人。因为多个人员可以有相同的生日,所以不是返回一个Person对象,而是用接口Enumerable-<Person>返回一个Person对象列表。所使用的Where方法根据 lambda表达式进行过滤。Where方法在名称空间System.Linq中定义:

1.7 用户定义的类型强制转换

C#允许定义自己的数据类型(结构和类),这意味着需要某些工具支持在自己的数据类型之间进行类型转换。方法是把数据类型转换定义为相关类的一个成员运算符,数据类型转换必须标记为隐式或显式,以说明如何使用它。我们应遵循与预定义数据类型转换相同的规则,如果知道无论在源变量中存储什么值,数据类型转换总是安全的,就可以把它定义为隐式转换。另一方面,如果某些数值可能会出错,例如丢失数据或抛出异常,就应把数据类型转换定义为显式转换。

定义数据类型转换的语法类似于本章前面介绍的重载运算符。但它们是不一致的,数据类型转换在某种情况下可以看作是一种运算符,其作用是从源类型转换为目标类型。为了说明这个语法,下面的代码是从本节后面介绍的结构Currency示例中节选的:

运算符的返回类型定义了数据类型转换操作的目标类型,它有一个参数,即要转换的源对象。这里定义的数据类型转换可以隐式地把Currency的值转换为float型。注意,如果数据类型转换声明为隐式,编译器可以隐式或显式地使用这个转换。如果数据类型转换声明为显式,编译器就只能显式地使用它。与其他运算符重载一样,数据类型转换必须声明为public和static。
C++开发人员应注意,这种情况与C++是不同的,在C++中,数据类型转换是类的实例成员。

1.7.1  执行用户定义的类型转换

本节将在示例SimpleCurrency(和往常一样,其代码可以下载)中介绍隐式和显式使用用户定义的数据类型转换。在这个示例中,定义一个结构Currency,它包含一个正的USD($)钱款。C#为此提供了decimal类型,但如果要进行比较复杂的财务处理,仍可以编写自己的结构和类来表示钱款,在这样的类上执行特定的方法。

数据类型转换的语法对于结构和类是一样的。我们的示例定义了一个结构,但如果把Currency声明为类,也是可以的。


Dollars和Cents字段使用无符号的数据类型,可以确保Currency实例只能包含正值。这样限制,是为了在后面说明显式转换的一些要点。可以像这样使用一个类来存储公司员工的薪水信息。人们的薪水不会是负值!为了使类比较简单,我们把字段声明为public,但通常应把它们声明为private,并为Dollars和Cents字段定义相应的属性。

下面先假定要把Currency实例转换为float值,其中float值的整数部分表示美元,换言之,应编写下面的代码:

为此,需要定义一个数据类型转换。给Currency定义添加下述代码:

这个数据类型转换是隐式的。在本例中这是一个合理的选择,因为在Currency定义中,可以存储在Currency中的值也都可以存储在float中。在这个转换中,不应出现任何错误。
但是,如果把float转换为Currency,就不能保证转换肯定成功了;float可以存储负值,而Currency实例不能,float存储的数值的量级要比Currency的(uint) Dollars字段大得多。所以,如果float包含一个不合适的值,把它转换为Currency就会得到意想不到的结果。因此,从float转换到Currency就应定义为显式转换。下面是我们的第一次尝试,这次不会得到正确的结果,但对解释原因是有帮助的:

下面的代码可以成功编译:

   float amount = 45.63f;

   Currency amount2 = (Currency)amount;

但是,下面的代码会抛出一个编译错误,因为试图隐式地使用一个显式的数据类型转换:

   float amount = 45.63f;

   Currency amount2 = amount;   // wrong

把数据类型转换声明为显式,就是警告开发人员要小心,因为可能会丢失数据。但这不是我们希望的Currency结构的执行方式。下面编写一个测试程序,运行示例。其中有一个Main()方法,它实例化了一个Currency结构,试图进行几个转换。在这段代码的开头,以两种不同的方式计算balance的值(因为要使用它们来说明后面的内容):

注意,所有的代码都放在一个try块中,来捕获在数据类型转换过程中发生的任何异常。在checked块中还添加了把超出范围的值转换为Currency的测试代码,所以,负值是肯定会被捕获的。运行这段代码,得到如下所示的结果:

这个结果表示代码并没有像我们希望的那样工作。首先,从float转换回Currency得到一个错误的结果$50.34,而不是$50.35。其次,在试图转换明显超出范围的值时,没有生成异常。
第一个问题是由舍入错误引起的。如果类型转换用于把float转换为uint,计算机就会截去多余的数字,而不是执行四舍五入。计算机以二进制方式存储数字,而不是十进制,小数部分0.35不能用二进制小数来精确表示(像1/3这样的分数不能精确表示为小数,它应等于循环小数0.3333)。所以,计算机最后存储了一个略小于0.35的值,它可以用二进制格式精确表示。把该数字乘以100,就会得到一个小于35的数字,截去了34美分。显然在本例中,这种由截去引起的错误是很严重的,避免该错误的方式是确保在数字转换过程中执行智能四舍五入操作。Microsoft编写了一个类System.Convert来完成该任务。System.Convert包含大量的静态方法来执行各种数字转换,我们需要使用的是Convert.ToUInt16()。注意,在使用System.Convert方法时会产生额外的性能损失,所以只应在需要时才使用它们。

下面看看为什么没有抛出期望的溢出异常。此处的问题是溢出异常实际发生的位置根本不在Main()例程中——它是在转换运算符的代码中发生的,该代码在Main()方法中调用,而且没有标记为checked。

其解决方法是确保类型转换本身也在checked环境下进行。进行了这两个修改后,修订后的转换代码如下所示。

注意,使用Convert.ToUInt16()计算数字的美分部分,如上所示,但没有使用它计算数字的美元部分。在计算美元值时不需要使用System.Convert,因为在此我们希望截去float值。

注意:

System.Convert的方法还执行它自己的溢出检查。因此对于本例的情况,不需要把对Convert.ToUInt16()的调用放在checked环境下。但把value显式转换为美元值仍需要checked环境。

这里没有给出这个新checked转换的结果,因为在本节后面还要对SimpleCurrency示例进行一些修改。

1. 类之间的数据类型转换

Currency示例仅涉及到与float(一种预定义的数据类型)来回转换的类。实际上任何简单数据类型的转换都是可以自定义的。定义不同结构或类之间的数据类型转换是允许的,但有两个限制:

●       如果某个类直接或间接继承了另一个类,就不能定义这两个类之间的数据类型转换(这些类型的类型转换已经存在)。

●       数据类型转换必须在源或目标数据类型的内部定义。

换言之,类C和D间接派生于A。在这种情况下,在A、B、C或D之间唯一合法的类型转换就是类C和D之间的转换,因为这些类并没有互相派生。这段代码如下所示(假定希望数据类型转换是显式的,这是在用户定义的数据类型之间转换的通常情况):

对于这些数据类型转换,可以选择放置定义的地方—— 在C的类定义内部,或者在D的类定义内部,但不能在其他地方定义。C#要求把数据类型转换的定义放在源类(或结构)或目标类(或结构)的内部。它的边界效应是不能定义两个类之间的数据类型转换,除非可以编辑它们的源代码。这是因为,这样可以防止第三方把数据类型转换引入类中。

一旦在一个类的内部定义了数据类型转换,就不能在另一个类中定义相同的数据类型转换。显然,只能有一个数据类型转换,否则编译器就不知道该选择哪个数据类型转换了。

2. 基类和派生类之间的数据类型转换

要了解这些数据类型转换是如何工作的,首先看看源和目标的数据类型都是引用类型的情况。考虑两个类MyBase 和 MyDerived,其中MyDerived直接或间接派生于MyBase。

首先是从MyDerived 到 MyBase的转换,代码如下(假定可以使用构造函数):

在本例中,是从MyDerived 隐式地转换为 MyBase。这是可行的,因为对类MyBase的任何引用都可以引用类MyBase的对象或派生于MyBase的对象。在OO编程中,派生类的实例实际上是基类的实例,但加上了一些额外的信息。在基类上定义的所有函数和字段也都在派生类上定义了。

下面看看另一种方式,编写下面的代码:

上面的代码都是合法的C#代码(从句法的角度来看,是合法的),是把基类转换为派生类。但是,最后的一个语句在执行时会抛出一个异常。在进行数据类型转换时,会检查被引用的对象。因为基类引用实际上可以引用一个派生类实例,所以这个对象可能是要转换的派生类的一个实例。如果是这样,转换就会成功,派生的引用被设置为引用这个对象。但如果该对象不是派生类(或者派生于这个类的其他类)的一个实例,转换就会失败,抛出一个异常。

注意,编译器已经提供了基类和派生类之间的转换,这种转换实际上并没有对对象进行任何数据转换。如果要进行的转换是合法的,它们也仅是把新引用设置为对对象的引用。这些转换在本质上与用户定义的转换不同。例如,在前面的SimpleCurrency示例中,我们定义了Currency结构和float之间的转换。在float到Currency的转换中,则实例化了一个新Currency结构,并用要求的值进行初始化。在基类和派生类之间的预定义转换则不是这样。如果要把MyBase实例转换为MyDerived对象,其值根据MyBase实例的内容来确定,就不能使用数据类型转换语法。最合适的选项通常是定义一个派生类的构造函数,它的参数是一个基类实例,让这个构造函数执行相关的初始化:

3. 装箱和拆箱数据类型转换

前面主要讨论了基类和派生类之间的数据类型转换,其中,基类和派生类都是引用类型。其规则也适用于转换值类型,但在转换值类型时,不是仅仅复制引用,还必须复制一些数据。
当然,不能从结构或基本值类型中派生。所以基本结构和派生结构之间的转换总是基本类型或结构与System.Object之间的转换(理论上可以在结构和System.ValueType之间进行转换,但一般很少这么做)。
从结构(或基本类型)到object的转换总是一种隐式转换,因为这种转换是从派生类型到基本类型的转换,即第2章中简要介绍的装箱过程。例如,Currency结构:

Currency balance = new Currency(40,0);

object baseCopy = balance;

在执行上述隐式转换时,balance的内容被复制到堆上,放在一个装箱的对象上,BaseCopy对象引用设置为该对象。在后台发生的情况是:在最初定义Currency结构时,.NET Framework隐式地提供另一个(隐式)类,即装箱的Currency类,它包含与Currency结构相同的所有字段,但却是一个引用类型,存储在堆上。无论这个值类型是一个结构,还是一个枚举,定义它时都存在类似的装箱引用类型,对应于所有的基本值类型,如int、double和 uint。不能也不必在源代码中直接编程访问这些装箱类型,但在把一个值类型转换为object时,它们是在后台工作的对象。在隐式地把Currency 转换为 object时,会实例化一个装箱的 Currency实例,并用Currency结构中的所有数据进行初始化。在上面的代码中,BaseCopy对象引用的就是这个已装箱的Currency实例。通过这种方式,就可以实现从派生类到基类的转换,并且,值类型的语法与引用类型的语法一样。

转换的另一种方式称为拆箱。与在基本引用类型和派生引用类型之间的转换一样,这是一种显式转换,因为如果要转换的对象不是正确的类型,会抛出一个异常:

object derivedObject = new Currency(40,0);

object baseObject = new object();

Currency derivedCopy1 = (Currency)derivedObject;   // OK

Currency derivedCopy2 = (Currency)baseObject;     // Exception thrown

上述代码的工作方式与前面的引用类型一样。把derivedObject转换为 Currency会成功进行,因为derivedObject实际上引用的是装箱 Currency实例—— 转换的过程是把已装箱的 Currency对象的字段复制到一个新的Currency结构中。第二个转换会失败,因为baseObject没有引用已装箱的 Currency对象。

在使用装箱和拆箱时,这两个过程都把数据复制到新装箱和拆箱的对象上,理解这一点是非常重要的。这样,对装箱对象的操作就不会影响原来值类型的内容

1.7.2  多重数据类型转换

在定义数据类型转换时必须考虑的一个问题是,如果在进行要求的数据类型转换时,C#编译器没有可用的直接转换方式,C#编译器就会寻找一种方式,把几种转换合并起来。例如,在Currency结构中,假定编译器遇到下面的代码:

首先初始化一个Currency实例,再把它转换为一个long。问题是不能定义这样的转换。但是,这段代码仍可以编译成功。因为编译器知道我们要定义一个从Currency到float的隐式转换,而且它知道如何显式地从float 转换为long。所以它会把这行代码编译为中间语言代码,首先把balance转换为float,再把结果转换为long。上述代码的最后一行也是这样,把balance转换为double型时,因为从Currency到 float的转换和从float 到double的转换都是隐式的,就可以在代码中把这个转换当作一种隐式转换。如果要显式地指定转换过程,可以编写如下代码:

但是,在大多数情况下,这会使代码变得比较复杂,因此是不必要的。下面的代码会产生一个编译错误:

Currency balance = new Currency(10,50);

long amount = balance;

原因是编译器可以找到的最佳匹配的转换仍是首先转换为flost,再转换为long,但从float到long的转换需要显式指定。

所有这些都不会带来太多的麻烦。转换的规则是非常直观的,主要是为了防止在开发人员不知情的情况下丢失数据。但是,在定义数据类型转换时如果不小心,编译器就有可能指定一条导致不期望的结果的路径。例如,假定编写Currency结构的其他小组成员要把一个uint转换为Currency,而该uint中包含了美分的总数(美分不是美元,因为我们不希望丢掉美元的小数部分),为此应编写如下代码:

注意,在这段代码中,第一个100后面的u可以确保把value/100u解释为uint。如果写成value/100,编译器就会把它解释为一个int型的值,而不是uint型的值。

在这段代码中清楚地注释了“不要这么做”。下面说明其原因。看看下面的代码段,它把包含350的uint转换为一个Currency,再转换回uint。那么在执行完这段代码后,bal2中又将包含什么?

答案不是350,而是3!这是符合逻辑的。我们把350隐式地转换为Currency,得到balance.Dollars=3,balance.Cents=50。然后编译器进行通常的操作,为转换回uint指定最佳路径。balance最终会被隐式地转换为float型(其值为3.5),然后显式地转换为uint型,其值为3。

问题是,在转换过程中如何解释整数是有矛盾的。从Currency到float的转换会把整数1解释为1美元,但从uint到Currency的转换会把这个整数解释为1美分,这是很糟糕的。如果希望类易于使用,就应确保所有的转换都按一种互相兼容的方式执行,即这些转换应得到相同的结果。在本例中,显然要重新编写从uint到Currency的转换,把整数值1解释为1美元:

public static implicit operator Currency (uint value)

{

   return new Currency(value, 0);

}

  • 28
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值