面向 C# 开发者的 C++ 2013 教程(四)
十一、表达式和运算符
为你的局限性争辩,毫无疑问,它们是你的。—理查德·巴赫,幻觉
在这一章中,我们将讨论表达式和操作符与 C# 的区别。我们将从一个警告开始:不要假设 C# 和 C++ 中的表达式求值是相同的。C# 和 C++/CLI 有不同的规则来控制表达式的计算,如果您正在编写过于复杂的表达式,这可能会让您感到惊讶。
这里有一个 C++ 的老把戏,经常出现在面试问题上。它不能保证按照 C++ 标准工作,但是它可以在 x86 的所有主要 C++ 编译器上工作。它被称为 XOR 交换,它允许您交换两个整数的值,而无需声明显式的临时。代码如下:
using namespace System;
void main()
{
int i=3, j=6;
Console::WriteLine("{0}, {1}", i, j);
i ^= j ^= i ^= j;
Console::WriteLine("{0}, {1}", i, j);
}
让我们运行它:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
3, 6
6, 3
看下面一行:
i ^= j ^= i ^= j;
如您所见,它交换了i和j的值,因为它的计算结果如下:
i ^= j;
j ^= i;
i ^= j;
第一个 XOR 交换将i和j之间的位差存储在i中。下一行根据这个差异改变j,变成i。最后一行通过将原来的i(当前为j)也改变为差异(当前为i),从而将i的剩余部分改变为j。这依赖于以下身份进行异或:
x == y ^ (x^y)
它或多或少地将x和y分成两部分:相同的部分和不同的部分,就像取两个数字并知道它们与平均值等距。
现在让我们试着用 C# 来做这件事:
using System;
class R
{
public static void Main()
{
int i=3, j=6;
Console.WriteLine("{0}, {1}", i, j);
i ^= j ^= i ^= j;
Console.WriteLine("{0}, {1}", i, j);
}
}
结果如下:
C:\>csc /nologo test.cs
C:\>test
3, 6
0, 3
如你所见,这是行不通的。大概就是缺少括号吧?如果我们试试这个呢?
i ^= (j ^= (i ^= j));
那也不行;我们得到同样的结果。这里的答案是 C# 和 C++ 对表达式求值的方式不同。这些规则相当复杂,除非你选择的职业是规则律师,否则你并不需要非常了解它们。
Note
在这种情况下,C# 代码的求值方式不同,因为 C# 将表达式的求值与变量的求值分开,以便帮助优化器。C++ 计算带括号的表达式;C# 可以自由地扫描整个语句,预先计算变量,并使用这些值来计算表达式。该代码依赖于在表达式中间更新的临时值i和j,以便正确工作。
避免这些深奥的结构是很好的编程实践。编写两种语言都能正常工作的代码的简单而安全的方法是细分表达式:
i ^= j;
j ^= i;
i ^= j;
这个序列在 C# 和 C++ 中都可以正常工作。十年前,将这些表达式编织在一起可能会产生更快的代码;今天的优化编译器已经足够成熟,可以计算出你要做什么,并补偿这种扩展。
运算符重载
C# 和 C++/CLI 最重要的方面之一是它们支持将用户定义的类型提升到内置类型的级别;其中一个重要的方面是定义操作符来处理新类型的能力。出版文献中最常见的例子定义了复杂变量或分数的类型,但这只是冰山一角。定义运算符来执行与其数学定义完全无关的运算也是常见的做法,这拓展了我们有限范式的边界,并经常重新定义新的范式。
当然,也有局限性,包括以下几点:
- 一元运算符必须保持一元;二元运算符必须保持二元。换句话说,您不能重新定义加号(
+)来接受三个参数而不是两个。 - 您不能编造不存在的运算符。您不能定义一个
/%操作符,即使它在逻辑上可以被语法消除歧义。您只能使用该语言的内置操作符。 - 您不能控制预定义的求值顺序,也不能期望复杂的表达式在 C++/CLI 和 C# 中以相同的方式求值。如前所述,C# 和 C++/CLI 有不同的规则来控制表达式的求值。
复数,一个基本例子
回想一下,我们可以考虑 C++/CLI 中的一个简单的复数类,复数是以下形式的数字:
a + bi
在哪里
这有助于我们为第十五章打下基础,当我们在以下形式的数字环境中使用模板重温复数时:
这种形式在处理黄金比例时非常有用:
利用黄金分割率,我们可以用一种非递归的、简单的、封闭的形式来计算斐波那契数列。 1
复数的回顾
使用复数的基本数学运算的回顾如下: 2
添加:
(a + bi) + (c + di) = (a + c) + (b+d)i
减法:
(a + bi) - (c + di) = (a - c) + (b - d)i
复杂共轭:
乘法:
(a+bi)(c+de)=(AC-BD)+(ad+BC)I
除以标量(实数):
复数之间的除法:使用复数共轭、乘法、标量除法和下列恒等式,
我们可以推导出复数之间的除法:
注意这个除法运算如何遵从复共轭以及乘法来计算商。
简单实现
我们通过定义类数据以及作用于数据的操作符来实现这个类。非常简单,类数据是对应于实部和虚部的两个双精度值,乘以以下内容:
数据结构如下:
value struct Complex
{
double re;
double im;
}
至于操作符本身,有几种方法来定义它们,这取决于我们是否希望我们的代码符合公共语言规范(CLS )(我们将在本章后面再讨论这一点)。本质上,我们的操作符是静态成员函数,它们返回对象而不是引用。
一元运算符
CLI 一元运算具有以下格式:
static``type``operator``op``(``type
我们将在我们的类中使用以下操作符:
复杂共轭:
static Complex operator ∼ (Complex a);
二元运算符
CLI 二进制操作具有以下格式:
static``type``operator``op``(``type``a,``type``b)
我们将在我们的类中使用这些运算符:
添加:
static Complex operator + (Complex a, Complex b);
减法:
static Complex operator - (Complex a, Complex b);
乘法:
static Complex operator * (Complex a, Complex b);
除以二:
static Complex operator / (Complex a, double b);
按复合体划分:
static Complex operator / (Complex a, Complex b);
秩序至关重要
注意,代码没有假设交换性;将a/b定义为不同于b/a是完全合理的,因此也可以实现以下代码行:
static Complex operator / (Complex a, double b)
用不同于这行的方法:
static Complex operator / (double a, Complex b)
我们努力的成果
完整的程序如下:
using namespace System;
value struct Complex
{
double re;
double im;
Complex(double re, double im)
{
this->re = re;
this->im = im;
}
static Complex operator + (Complex a, Complex b)
{
return Complex(a.re+b.re, a.im+b.im);
}
static Complex operator - (Complex a, Complex b)
{
return Complex(a.re-b.re, a.im-b.im);
}
static Complex operator ∼ (Complex a)
{
return Complex(a.re, - a.im);
}
static Complex operator * (Complex a, Complex b)
{
return Complex(a.re*b.re - a.im*b.im, a.re*b.im + a.im*b.re);
}
static Complex operator / (Complex a, Complex b)
{
return a / (b.re*b.re+b.im*b.im) * ∼b;
}
virtual String ^ ToString() override
{
String ^s = re.ToString();
if(im != 0)
{
return s += " + " + im.ToString() + "i";
}
return s;
}
private:
static Complex operator / (Complex a, double f)
{
return Complex(a.re/f, a.im/f);
}
};
void main()
{
Complex a(-5,10), b(3,4);
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
}
正如您所看到的,基本操作符+、-、*和/已经被重载来操作Complex类型,而不是它们所基于的子类型,在本例中是double。
布尔逻辑中的一元补码运算符∼并不直观地对应于你在实数上对复数执行的任何运算。因此,它是满足我们对复共轭一元运算符需求的理想候选,我们需要实现operator/。除了参数数量和参数类型之外,编译器不会强制任何逻辑范例。你可以自由定义operator*为除法,operator/为乘法。这当然是不好的形式,除非混淆视听是你的目标。
快速编译和运行的结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
(-5 + 10i) / (3 + 4i) = 1 + 2i
过载的解决方案
你可能还注意到有两种不同的除法方法。C# 和 C++ 都有选择调用哪个方法的内置规则,但这些规则的不同之处令人惊讶。两个名称相同但参数不同的方法称为重载。为给定的一组参数确定最匹配的过程被称为重载决策,我在这里将在operator/的上下文中介绍它,尽管它将继续是我们顺便涉及的一个主题。
假设我们用下面的函数替换前面的main()函数:
void main()
{
Complex a(-5,10);
float b = 5.0f;
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
}
让我们运行这个例子:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
(-5 + 10i) / (5) = -1 + 2i
当这个例子被执行时,编译器需要查找如何计算a/b,其中变量a的类型为Complex,变量b的类型为float。
编译器解析a/b并开始寻找兼容的方法
operator/(Complex a,float b)
在程序的源代码中,没有方法具有这种精确的签名,所以编译器收集了一个可能的候选方法列表,并试图确定最佳匹配。在这种情况下,可能的选择如下:
operator/(Complex a,double b)
operator/(Complex a,Complex b)
这两个都不完全匹配。C++ 标准中有明确定义的规则来管理重载的解析,这些规则不仅适用于运算符,也适用于一般的函数。我不想在这个问题上纠缠太多;现在,要知道在这种情况下直觉的选择是赢家。允许的操作是将float提升(扩展)到double并选择以下选项:
operator/(Complex a,double b)
重载决策的规则提供了一种处理隐式和显式转换的多层方法。某些转换优于其他转换,这绝不是任意的。乍一看,这似乎不是一个充满危险的话题,但请考虑以下情况:假设我们在代码中添加了一个从double到Complex的隐式转换。如果编译器可以自动执行这种转换,我们还会担心创建无限递归吗?由于operator/(Complex, Complex)调用operator/ (Complex, double),添加一个从double到Complex的隐式转换可能会导致歧义或无限循环。在这种情况下,这是因为 C++ 规范中的优先级规则,它为每种类型的转换分配一个等级,并根据等级对它们进行优先级排序。我们将在本章后面讨论隐式和显式转换。
当你认为复数的主题变得过于数学化时,请做好准备——我很高兴向你展示下面的数学转移。
数学转移:数字模素数
C# 和 C++/CLI 都使用百分号作为运算符来计算一个数对另一个数的模。回想一下,当number除以p时,(number%p)等于余数。很容易定义一类以一个数为模的数p。下面就是这组简单的数字:
{0,1, .。。(p - 1)}
现在我们只需要弄清楚如何对它们进行操作。
通过计算结果模p,我们可以很容易地重新定义加法、乘法和减法的基本运算符。除法通常会让我们遇到分数的使用,但是初等数论的一个结果告诉我们,当模数p是质数时,除法可以不用分数来定义。例如,让我们考虑以 13 为模的数字,假设我们正在试图计算四分之一,1 除以 4,是多少。换句话说,4 的倒数是多少?
一个简单的计算表明(4 * 10) % 13 = 1,由于4*10=40=39+1,因此 1 是 40 除以 13 的余数。
让我们用编译器来证明这一点:
using namespace System;
void main()
{
Console::WriteLine("4 * 10 = {0} (13)", 4*10%13);
}
当我们编译并执行它时,我们得到了以下内容:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
4 * 10 = 1 (13)
10 是 4 的倒数。如果我们将两边除以 4,我们得到如下结果:
因为
同样,所有以 13 为模的非零数字都以同样的方式求逆。为了找到它,我们必须使用数论的另一个结果。事实证明,对于每两个数字 a 和 b,都存在数字 x 和 y
如果其中一个数是质数,另一个不是这个质数的倍数,那么这两个数的最大公约数(gcd)就是 1,我们得到如下:
因为根据定义,p 的任何倍数都是 0,所以我们得到
阅读前面的表达式如下:py 等于 0 模 p,因为当 py 除以 p 时余数为 0。结合这些事实,我们得出结论,存在一个数 x,使得
换句话说,我们只需要找到数字 x,我们就有了它的逆!我不会用更多的细节或推导来烦你,但是有一个扩展版本的欧几里德算法可以帮你做到这一点。 3 它在下面的代码中;请注意,ExtendedEuclid()是作为一个全局函数实现的,而不是一个类方法,它将对整数的引用作为它的一些参数:
using namespace System;
void ExtendedEuclid(int a, int b, int %d, int %x, int %y)
{
if(b==0)
{
d=a;
x=1;
y=0;
}
else
{
ExtendedEuclid(b,a%b, d, y, x);
y-= (a/b)*x;
}
}
value struct F13
{
unsigned Value;
initonly static unsigned P = 13;
F13(unsigned Val)
{
Value = Val % P;
}
static F13 operator * (F13 arg1, F13 arg2)
{
return F13((arg1.Value * arg2.Value) % P);
}
static F13 operator + (F13 arg1, F13 arg2)
{
return F13((arg1.Value + arg2.Value) % P);
}
static F13 operator - (F13 arg1, F13 arg2)
{
return F13((arg1.Value - arg2.Value) % P);
}
static F13 operator - (F13 arg1)
{
return F13((P - arg1.Value) % P);
}
static F13 operator / (F13 arg1, F13 arg2)
{
int d, x, y;
ExtendedEuclid(arg2.Value,P,d,x,y);
return arg1*F13(x*d);
}
virtual String ^ ToString() override
{
Value = (Value+P) % P;
String ^s = Value.ToString();
return s;
}
};
void main()
{
F13 a(6), b(9), c(4), d(10);
Console::WriteLine("{0} * {1} is {2}", a, b, a*b);
Console::WriteLine("{0} / {1} is {2}", a, b, a/b);
Console::WriteLine("{0} * {1} is {2}", c, d, c*d);
}
结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
6 * 9 is 2
6 / 9 is 5
4 * 10 is 1
内置类型的隐式和显式转换
C# 和 C++/CLI 都支持定义类型之间的隐式和显式转换。这是用户定义的类型,相当于将float提升为double,或者将short提升为int。隐式转换是编译器可以自动应用的转换,而显式转换需要 cast 运算符。让我们谈一谈内置类型之间的转换。
C# 和 C++ 之间的转换差异
不幸的是,内置类型的隐式转换在 C++ 和 C# 之间有所不同。众所周知,C++ 在防止有数据丢失风险的转换方面非常松懈。考虑以下示例:
using namespace System;
void main()
{
long l=65537;
short s=0;
s=l;
l=s;
Console::WriteLine(l);
}
现在让我们试一试:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
1
在这种情况下,编译器会在short和long之间进行隐式转换,反之亦然,并且不会发出可能丢失数据的警告。如果我们将警告级别提高到 3,我们会得到以下输出:
C:\>cl /clr:pure /nologo /W3 test.cpp
test.cpp(6) : warning C4244: '=' : conversion from 'long' to 'short', possible loss
of data
这才像话!
假设我们将long改为int,并在警告级别 3 进行编译。数据丢失仍然存在:
C:\>cl /clr:pure /nologo /W3 test.cpp
C:\>test
1
充其量,这是一个烦恼。在最坏的情况下,这是一个召回类的错误。幸运的是,如果我们将警告级别提高到 4 级,我们会得到以下结果:
C:\>cl /clr:pure /nologo /W4 test.cpp
test.cpp
test.cpp(6) : warning C4244: '=' : conversion from 'int' to 'short', possible loss
of data
4 级警告的唯一问题是,它们被认为是建议性的,而不是诊断性的,有时会出现虚假或嘈杂的警告。吸取的教训是你需要小心。在这方面,C++ 编译器不像 C# 编译器那样小心翼翼,正如我以前所建议的,当你开发代码时,不时地打开/W4警告。
有符号/无符号不匹配
如果您试图将有符号值赋给无符号变量,C++/CLI 编译器会发出警告,反之亦然。默认情况下,它是禁用的,但是可以使用/Wall编译器选项来启用,这将启用编译中默认禁用的所有警告。
例如,考虑以下情况:
void main()
{
unsigned u=0;
int i=0;
i=u;
}
编译后,我们得到
C:\>cl /Wall /nologo test.cpp
test.cpp(5) : warning C4365: '=' : conversion from 'unsigned int' to 'int',
signed/unsigned mismatch
整数转换表
让我们回顾一下 C++ 和 C# 中的一些内置转换。使用以下缩写列表作为解释整数换算表的关键(表 11-1 至表 11-5 ):
例如:明确的
im:隐式,没有警告
i2:隐含,警告级别 2
i3:隐含,警告级别 3
i4:隐含,警告级别 4
ia:隐式,仅警告/Wall(表示有符号/无符号不匹配)
x:不需要转换
我们先来看看表 11-1 中的整数类型。
表 11-1。
C++/CLI Conversion Table for a Sampling of Built-in Integer Types
让我们看看 C# 表中的表 11-2 中的整数类型。
表 11-2。
C# Conversion Table for a Sampling of Built-in Integer Types
请记住,当您阅读这些表格时,您必须考虑到long在 C# 中的含义与在 C++ 中的含义不同。在 C++/CLI 中,long和int都是System::Int32的别名,long long用于System::Int64,而 C# 是通过使用long来实现的。表 11-3 摘自第六章中的类型表。
表 11-3。
A Partial Type Table
现在让我们看看换算表。您可能会注意到,在 C# 表中没有可能丢失数据。我是 C++ 的拥护者,但我必须承认,在这方面我更喜欢 C# 实现。还要注意,C++/CLI 也将 C# 认为是隐式的每个转换视为隐式的,但在从unsigned int扩展到long时仍会报告有符号/无符号不匹配。
好消息是,当我们查看这些表格时,我们发现 C++/CLI 有办法获得与 C# 中相同的警告级别。这不直观但很简单——不要用int。
在 C++/CLI 中,int和long都映射到System::Int32,而unsigned int和unsigned long都映射到System::UInt32,但 C++ 编译器出于警告目的对它们进行了不同的处理。造成这种情况的大部分原因是历史原因;short和long最初被定义为目标架构支持的最小和最大整数大小。类型int被定义为目标架构的最有效尺寸。随着时间的推移,实现发现这种浮动定义使得在平台之间移植程序成为问题。这导致了上的当前实现。NET 将short固定为 16 位,将int和long固定为 32 位。类型long long被添加到. NET 的 64 位语言中。
对于其他目标体系结构,int要么被实现为short要么被实现为long,这使得警告的发布成为问题。非官方的编程实践是,当你不真正关心转换问题并且需要快速、高效的代码时,使用int;如果数据本身需要,你可以使用short或long。这种做法今天仍然适用于。NET:在int上使用short和long,编译器会做好自己的工作并发出警告。
现在我们来看看浮点转换。
浮点转换表
让我们在表 11-4 中考察一些 C++ 中的交叉转换。
表 11-4。
C++ Conversion Table for Floating Point Types and for a Sampling of Integer Types
让我们在表 11-5 中检查一些 C# 中的交叉转换。
表 11-5。
C# Conversion Table for Floating Point Types and for a Sampling of Integer Types
关于浮点转换表需要注意的一件有趣的事情是,C++ 在将int提升为float时会发出 2 级警告,但在将int提升为double时不会。考虑下面的片段:
using namespace System;
void main()
{
int i0 = int::MaxValue;
int i;
float f;
double d;
f = i0;
i = f;
Console::WriteLine("int {0}, to float {1}, back to int {2}", i0, f, i);
d = i0;
i = d;
Console::WriteLine("int {0}, to double {1}, back to int {2}", i0, d, i);
}
在本例中,我们取最大正整数,并将其转换为一个float,然后返回,数据会丢失。尽管int和float都是 4 个字节长,float使用其中的一些位来存储指数和符号信息,所以它不能完全精确地存储整数信息。如果您使用double进行类似的往返,则不会有数据丢失。因此,警告是正确的:
C:\>cl /clr:pure /nologo /W4 test.cpp
test.cpp(8) : warning C4244: '=' : conversion from 'int' to 'float', possible loss
of data
test.cpp(9) : warning C4244: '=' : conversion from 'float' to 'int', possible loss
of data
test.cpp(12) : warning C4244: '=' : conversion from 'double' to 'int', possible loss
of data
C:\>test
int 2147483647, to float 2.147484E+09, back to int -2147483648
int 2147483647, to double 2147483647, back to int 2147483647
你可能认为 C# 编译器有问题,因为它允许从int到float的转换,而没有警告可能的数据丢失。然而,C# 要求从float到int的转换有一个显式的转换或强制转换,所以你可以说出站方向的警告是多余的。C# 代码如下:
using System;
class R
{
public static void Main()
{
int i0 = int.MaxValue;
int i;
float f;
double d;
f = i0;
i = (int)f;
Console.WriteLine("int {0}, to float {1}, back to int {2}", i0, f, i);
d = i0;
i = (int)d;
Console.WriteLine("int {0}, to double {1}, back to int {2}", i0, d, i);
}
}
C# 版本的结果如下:
C:\>csc /nologo test.cs
C:\>test
int 2147483647, to float 2.147484E+09, back to int -2147483648
int 2147483647, to double 2147483647, back to int 2147483647
请注意,C# 版本需要显式转换才能编译。
用户定义的转换
与编译器定义内置类型之间的隐式和显式转换相同,用户也可以定义用户定义类型之间的隐式和显式转换。在 C# 中,你使用implicit和explicit关键字。在 C++/CLI 中,默认情况下转换是隐式的,您可以使用explicit关键字来指定显式转换。
隐式转换
在我们的Complex类中,我们使用了一个私有的 helper 函数来定义一个复数除以一个double。为什么不揭露这个?除此之外,为什么不允许用户将一个复数乘以一个double或者将一个double乘以一个复数呢?
我们可以为这些操作中的每一个编写特定的重载,或者我们可以定义一个隐式操作符,将一个double转换成一个Complex。在这里,在 C++/CLI 语法中;它是一个静态成员函数,接受一个double参数:
static operator Complex(double re)
{
return Complex(re,0);
}
现在,用户可以对复数和双精度数执行所有基本的数学运算。下面是一个新版本的main()使用了这个隐式操作符:
void main()
{
Complex a(-5,10), b(3,4);
double c(3.5);
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
Console::WriteLine("({0}) * ({1}) = {2}",a,c,a*c);
Console::WriteLine("({0}) / ({1}) = {2}",c,a,c/a);
}
编译和运行后,我们得到以下内容:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
(-5 + 10i) / (3 + 4i) = 1 + 2i
(-5 + 10i) * (3.5) = -17.5 + 35i
(3.5) / (-5 + 10i) = -0.14 + -0.28i
对于一点点工作来说,这是很大的能量。从一个Complex到一个double的另一个方向呢?
显式转换
从Complex到double将会丢失一些数据,因此这不应该是隐式转换。这可能意味着什么?是不是应该把复数投影到实线上,只返回复数的实部?应该返回复数的大小吗?向double的转变应该存在吗?走哪条路真的要由我们来决定。
就我个人而言,我被使用量值的想法所吸引:
除了关键字explicit之外,显式转换看起来与隐式转换一样:
static explicit operator double(Complex c)
{
return Math::Sqrt(c.re*c.re + c.im * c.im);
}
为了简洁起见,我们可以替换以下代码:
static Complex operator / (Complex a, Complex b)
{
return a / (b.re*b.re+b.im*b.im) * ∼b;
}
随着
static Complex operator / (Complex a, Complex b)
{
return a / ((double)b * (double)b) * ∼b;
}
这给了我们以下完成的程序:注意operator/(Complex, double)不再是private:
using namespace System;
value struct Complex
{
double re;
double im;
Complex(double re, double im)
{
this->re = re;
this->im = im;
}
static Complex operator + (Complex a, Complex b)
{
return Complex(a.re+b.re, a.im+b.im);
}
static Complex operator - (Complex a, Complex b)
{
return Complex(a.re-b.re, a.im-b.im);
}
static Complex operator ∼ (Complex a)
{
return Complex(a.re, - a.im);
}
static Complex operator * (Complex a, Complex b)
{
return Complex(a.re*b.re - a.im*b.im, a.re*b.im + a.im*b.re);
}
virtual String ^ ToString() override
{
String ^s = re.ToString();
if(im != 0)
{
return s += " + " + im.ToString() + "i";
}
return s;
}
static Complex operator / (Complex a, Complex b)
{
return a / ((double)b * (double)b) * ∼b;
}
static operator Complex(double re)
{
return Complex(re,0);
}
static explicit operator double(Complex c)
{
return Math::Sqrt(c.re*c.re + c.im * c.im);
}
static Complex operator / (Complex a, double f)
{
return Complex(a.re/f, a.im/f);
}
};
void main()
{
Complex a(-5,10), b(3,4);
double c(3.5);
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
Console::WriteLine("({0}) * ({1}) = {2}",a,c,a*c);
Console::WriteLine("({0}) / ({1}) = {2}",c,a,c/a);
}
符合 CLS 标准的运营商
在 C++ 中有几种定义操作符的方法,这取决于你的意图和目标。在这种情况下,目标是创建一个符合 CLS 标准的应用程序。公共语言规范(CLS)定义了如何使代码与多种 CLI 语言兼容。因此,在编写与 C# 或其他链接的程序时,最好采用这种范式。网络语言。当满足以下所有标准时,我们认为运营商符合 CLS 标准:
- 如 CLS 所述,该操作器列在符合 CLS 标准的表格中。
- 运算符是引用或值类的静态成员。
- 运算符函数的参数和返回值不由任何指针、引用或句柄传递或返回。
让我们检查一下表 11-6 中符合 CLS 的一元运算符。 4
表 11-6。
CLS-Compliant Unary Operators
| 操作员名 | 函数名 | C# | C++ | | --- | --- | --- | --- | | `operator&` | `AddressOf` | 不 | 是 | | `operator!` | `LogicalNot` | 是 | 是 | | `operator∼` | `OnesComplement` | 是 | 是 | | `operator*` | `PointerDereference` | 不 | 是 | | `operator-` | `UnaryNegation` | 是 | 是 | | `operator+` | `UnaryPlus` | 是 | 是 | | `operator true` | `true` | 是 | 不 | | `operator false` | `false` | 是 | 不 |让我们检查一下表 11-7 中符合 CLS 的二元运算符。
表 11-7。
CLS-Compliant Binary Operators
| 操作员名 | 函数名 | C# | C++ | | --- | --- | --- | --- | | `operator+` | `Addition` | 是 | 是 | | `operator&` | `BitwiseAnd` | 是 | 是 | | `operator|` | `BitwiseOr` | 是 | 是 | | `operator,` | `Comma` | 是 | 是 | | `operator--` | `Decrement` | 是 | 是 | | `operator/` | `Division` | 是 | 是 | | `operator==` | `Equality` | 是 | 是 | | `operator^` | `ExclusiveOr` | 是 | 是 | | `operator>` | `GreaterThan` | 是 | 是 | | `operator>=` | `GreaterThanOrEqual` | 是 | 是 | | `operator++` | `Increment` | 是 | 是 | | `operator!=` | `Inequality` | 是 | 是 | | `operator<<` | `LeftShift` | 是 | 是 | | `operator<` | `LessThan` | 是 | 是 | | `operator<=` | `LessThanOrEqual` | 是 | 是 | | `operator&&` | `LogicalAnd` | 不 | 是 | | `operator||` | `LogicalOr` | 不 | 是 | | `operator%` | `Modulus` | 是 | 是 | | `operator*` | `Multiply` | 是 | 是 | | `operator>>` | `RightShift` | 是 | 是 | | `operator-` | `Subtraction` | 是 | 是 |这些操作符中的大多数都是不言自明的。只有几个值得特别一提:
operator*可以是Multiply也可以是PointerDereference,这取决于它是二元还是一元运算符。operator&可以是BitwiseAnd也可以是AddressOf,这取决于它是二元还是一元运算符。operator&&和operator||都可以在 C++/CLI 中重载。这些不能在 C# 中重载。- C++ 中没有实现
operator true和operator false。
运算符 true 和运算符 false
operator true和operator false在下面的 C# 代码中使用,在 C++/CLI 中不能类似地编写:
using System;
class R
{
int value;
R(int V)
{
value = V;
}
public static bool operator true (R r)
{
return r.value!=0;
}
public static bool operator false ( R r)
{
return r.value==0;
}
public void Test(String name)
{
if(this)
{
Console.WriteLine("{0} is true", name);
}
else
{
Console.WriteLine("{0} is false", name);
}
}
public static void Main()
{
R r3 = new R(3);
r3.Test("r3");
R r0 = new R(0);
r0.Test("r0");
}
}
如果在 C# 中编译并运行,您会得到以下结果:
C:\>csc /nologo test.cs
C:\>test
r3 is true
r0 is false
在 C++ 中有一个很好的解决方法,使用到bool的隐式转换:
using namespace System;
ref class R
{
private:
int value;
R(int V)
{
value = V;
}
public:
static operator bool(R^ r)
{
return r->value != 0;
}
void Test(String^ name)
{
if(this)
{
Console::WriteLine("{0} is true", name);
}
else
{
Console::WriteLine("{0} is false", name);
}
}
static void Main()
{
R ^r3 = gcnew R(3);
r3->Test("r3");
R ^r0 = gcnew R(0);
r0->Test("r0");
}
};
void main()
{
R::Main();
}
其他操作员
C++ 允许你重载赋值操作符、函数调用(operator())和索引(operator[]),其方式不符合 CLS 标准。
摘要
对表达式和操作符有良好的感觉在面向对象编程中是很重要的。它们允许您扩展您的类,并像处理内置类型一样处理它们。
在下一章,我们将通过补充一些在前几章的大类中没有涉及到的细节来完成我们的基本 C++ 之旅。在那之后,你将为后面的章节做好更深入和详细的准备。
Footnotes 1
计算机编程艺术,第一卷:基本算法,第三版。(波士顿:艾迪森-韦斯利出版社,1997 年)。
2
这些是从复数域的结合律、交换律和分配律以及i的定义中推导出来的。
3
《算法导论》,第二版。(麻省剑桥:麻省理工学院出版社,2001 年)。
4
这些表格来自 C++/CLI 语言规范。
十二、开始的结束
余,要不要我教你什么是知识?当你知道一件事时,要意识到你知道它;当你不知道一件事时,允许你不知道:这就是知识。—孔子
在这一章中,我们将填补空白,完成对 basic C++ 的介绍。我们将讨论包含文件、范围解析、各种操作符细节和异常。
包含文件
C# 和 C++/CLI 的一个主要区别是 C# 的多通道特性。C++ 是作为 C 语言的一个包装器而设计的,C 语言的设计目的是在功能远不如当代家用个人电脑的电脑上运行,现代家用个人电脑由于速度太慢,除了查看电子邮件之外,不能做任何事情。因此,C 语言本质上是一种单程语言,因为编译器可以编译整个程序,一条语句一条语句地将 C 语言消化并翻译成汇编语言。这需要程序员安排代码,首先是基本子程序,然后是调用基本子程序的子程序,最后是调用前面任何子程序的main()过程。
这一要求适用于简单的代码,但不适用于子例程或类交叉引用的情况,这在日常编码中很常见。一种通常交叉引用的常见数据结构是类型安全函数回调:一个类的方法调用第二个类来注册对第一个类的回调。
远期申报
为了解决这个问题,C 语言增加了向前声明的概念。前向声明是一个函数或类的原型,它告诉编译器某个标识符是一个类或函数,或者提供类结构本身的细节。C++ 在给类添加方法的时候,也扩展了类的设计,让类本身不是单通而是双通元素。如果你想知道,在 C 中,一个类是用关键字struct声明的。
C++ 是一种一次通过的语言,但是类本身在两次通过中被解析。因此,当您在定义函数或变量之前访问它时,需要前向声明,除非它们是同一类的成员。此外,C++ 首先将文件编译成目标文件,并需要声明来使用称为链接器的程序将这些目标文件绑定到单个模块或可执行文件中。
相比之下,C# 不仅本质上是一种两遍语言,而且在编译时会同时考虑单个模块的所有文件。当添加越来越多的源文件而没有细分成模块时,C# 的编译就变得越来越低效。另一方面,C++ 伸缩性很好,因为每个源文件都会生成一个新的目标文件。此外,声明描述了文件的外部内容,因此可以添加新文件并重新编译,而不需要完全重建整个模块。
问题
C++ 方法的优点和缺点是相当清楚的。由于 C++ 需要准确的声明才能正确编译并将目标文件绑定在一起,如果声明不准确会发生什么?如果你改变了一个函数的定义,而没有改变该函数相应的声明,会发生什么?如果这些变得不同步,程序就不能正确编译。更糟糕的是,你可能在编译的顺序上犯了一个错误,它可能会编译,因此链接器将会把一个特定函数的使用与另一个函数的函数定义绑定在一起。
解决方案
为了保持所有的前向声明和原型同步,C 和 C++ 都使用包含文件来跟踪原型。包含文件是包含声明和原型的文件,这些声明和原型在程序编译时包含在程序的源代码中。通过这种方式,一个包含文件可以被多个源文件共享,因此每个人总是使用最新版本的原型和声明。如果包含文件发生更改,可以使用 make 程序强制重新编译包含它的所有源文件。如果包含文件中项的声明与源文件中相应的定义不同步,编译器会发出一个错误,该声明可以被更正。对于包含声明、类定义和预处理器定义的头文件,Include 文件通常具有扩展名.h,对于同样包含实例化和变量定义的头文件,则具有扩展名.hxx和.hpp。
包括文件警告
注意,这个范例是用户定义的。如果代码来自包含文件而不是顶级源文件,编译器不会对其进行不同的编译——对编译器来说,C++ 就是 C++。因此,如果将定义放在包含文件中,可能会遇到麻烦。如果一个定义包含在多个源文件中,那么它会在每个源文件中定义,最终会出现一个多重定义的符号错误。例如,考虑以下三个文件:
// test1.cpp
#include "a.h"
// test2.cpp
#include "a.h"
// a.h
void hello()
{
}
使用以下命令行编译test1.cpp和test2.cpp:
C:\>cl /nologo test1.cpp test2.cpp
test1.cpp
test2.cpp
Generating Code...
test2.obj : error LNK2005: "void __cdecl hello(void)" (?hello@@YAXXZ) already
defined in test1.obj
LINK : fatal error LNK1561: entry point must be defined
注意,我们还会得到一个链接器错误,因为全局函数main()在我们的任何源文件中都找不到。
声明的类型
声明有各种形状和大小。它们可以定义类或全局对象,并且类声明可以发生在从没有定义到完整定义的任何情况下。
没有定义的类声明
当编译文件不需要了解类的内部结构时,声明标识符是一个类就足够了,如下例所示:
class A;
class B
{
A *pA;
};
因为我们只有一个指向类型A的指针,所以为了给指针pA分配空间,类B不需要知道A的定义——简单的声明就足够了。
带定义的类声明
以下代码无法编译:
class A;
class B
{
A a;
};
编译器被要求用每个B实例创建一个A实例。在这个例子中,编译器需要知道A的大小和结构,然后才能完成B的定义。定义A来修复代码。
编译器输出如下:
C:\>cl /nologo test.cpp
test.cpp(4) : error C2079: 'B::a' uses undefined class 'A'
全局变量的声明
全局变量可以用关键字extern声明,也可以不用,如下所示:
class A;
extern int i;
extern A * pA;
默认情况下,在全局范围内声明的变量是extern,这意味着它们在声明它们的编译单元之外是可见的。这也称为外部链接。外部链接的反义词是内部链接,内部链接是用static关键字实现的。
类的声明和定义的分离
类的声明可以从定义中分离出来,如下所示:
//include file (a.h)
class A
{
A();
void Method();
};
//source file (a.cpp)
A::A()
{
}
void A::Method()
{
}
在这个例子中,类A的声明可以出现在一个包含文件中,该文件包含在几个源文件中,而使用完全限定名的各个方法的定义出现在单个源文件中。回想一下,C++ 不支持分部类;分部类与 C++ 范式不兼容。在 C# 中,编译器可以在所有源文件中搜索分部类定义,并在定义类之前将它们收集在一起。在 C++ 中,这是不可能的。
对类解析的多遍性质的隐式前向引用
以下代码在 C++ 中运行良好,即使没有前向声明。真正的问题是编译器用的是哪个版本的Hello()?
using namespace System;
void Hello()
{
Console::WriteLine("::Hello");
}
ref class R
{
public:
R()
{
Hello();
}
void Hello()
{
Console::WriteLine("R::Hello");
}
};
void main()
{
R ^r = gcnew R();
}
让我们试试看:
C:\>cl /nologo /clr test.cpp
C:\>test
R::Hello
如你所见,Hello()的类版本优先于全局版本,即使从构造器调用Hello()时R::Hello()还没有出现在代码中。类版本总是比全局版本更受青睐,因为它在范围上更接近全局版本,并且类版本将是候选版本,因为在解析任何定义之前,会先解析整个类的声明。这是 C++ 类解析的两遍性质的一个例子。这个实现与 C++ 的作用域规则是一样的,它规定在类作用域内部,类作用域优先于全局作用域。
范围解析运算符
在前面的例子中,如果我们想要调用函数Hello()的全局版本而不是类版本呢?在 C++ 中,通过在名称前面加冒号来指定全局命名空间。例如,让我们将构造器更改如下:
R()
{
::Hello();
Hello();
}
现在让我们看看会发生什么:
C:\>cl /nologo /clr test.cpp
C:\>test
::Hello
R::Hello
可以看到,第一个调用::Hello()调用了全局函数,第二个调用Hello()调用了成员函数。在标识符前面加上冒号-冒号范围解析运算符,开始在全局命名空间中搜索标识符。
在下面的例子中,所讨论的标识符不是一个全局函数,而是另一个类的成员函数:
using namespace System;
ref struct Outer
{
ref struct Inner
{
Inner()
{
Console::WriteLine(__FUNCSIG__);
}
ref struct Outer
{
ref struct Inner
{
Inner()
{
Console::WriteLine(__FUNCSIG__);
}
public:
static void Test()
{
Outer::Inner ^m0 = gcnew Outer::Inner();
::Outer::Inner ^m1 = gcnew ::Outer::Inner();
}
};
};
};
};
void main()
{
Outer::Inner::Outer::Inner::Test();
}
在这段代码中,我们使用__FUNCSIG__宏来打印每个Inner()方法的签名。如果我们编译并运行它,我们会得到以下结果:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
__clrcall Outer::Inner::Outer::Inner::Inner(void)
__clrcall Outer::Inner::Inner(void)
该输出显示,第二次分配开始时的冒号-冒号范围解析操作符引用了全局上下文中的Outer::Inner,而不是本地上下文。
可空类型
的 2.0 版。NET Framework 添加了可空数据类型。可空数据类型是基础类型的扩展,它将null值添加到基础类型的合法值集合中。C++/CLI 和 C# 都支持可空类型。
C# 和 C++/CLI 中的示例
在 C# 中,可空类型的构造是通过在类型名后附加一个问号来表示的。例如,考虑下面的可空实例bool的 C# 代码:
class R
{
static void Main()
{
bool? b = null;
if(b != null)
{
System.Console.WriteLine(b);
}
else
{
System.Console.WriteLine("null");
}
}
}
表情
bool? b = null;
基于一个bool声明一个可空类型,并将其设置为null。下一行包含以下表达式:
(b != null)
这个表达式反过来确定b是否已经被设置为null。C++/CLI 也支持可空类型,但语法不那么简单。C++/CLI 语法更接近于反映生成的 IL,而 C# 语法处于更高的抽象层次。
为了在 C++/CLI 中使用可空类型,您需要执行以下操作:
- 基于关键字
Nullable对可空类型使用伪模板语法。 - 使用
System名称空间;Nullable<>就是在这个命名空间中定义的。 - 当你赋值给一个可空类型的实例时,使用
Nullable<type>()而不是null。 - 在比较表达式中使用实例时,使用属性
HasValue而不是与null进行比较。
随后的 C# 声明和初始化行
bool? b = null;
在 C++/CLI 中变成如下形式:
Nullable<bool> b = Nullable<bool>();
这个 C# 表达式
(b != null)
在 C++/CLI 中变成如下形式:
(!b.HasValue)
考虑到这些指导原则和转换,我们发现 C++/CLI 代码可以完成同样的事情:
using namespace System;
ref struct R
{
static void Main()
{
Nullable<bool> b = Nullable<bool>();
if(!b.HasValue)
{
System::Console::WriteLine("null");
}
else
{
System::Console::WriteLine(b);
}
}
};
void main() {R::Main();}
执行任一版本都会产生以下结果:
null
请注意,可空数据类型可以从用户定义的类型以及内置类型中创建。考虑以下基于用户定义类型V的示例,它也显示null:
using namespace System;
value struct V {};
ref struct R
{
static void Main()
{
Nullable<V> b = Nullable<V>();
if(!b.HasValue)
{
System::Console::WriteLine("null");
}
}
};
void main() {R::Main();}
那个??C# 中的运算符
在 C# 中,??是一个二元运算符,用于可空类型。如果不等于null,则计算第一个参数,否则计算第二个参数。尽管 C++/CLI 不支持这种语法,但编写代码来执行相同的操作还是很简单的。
考虑下面的 C# 代码:
using System;
class R
{
static void Main()
{
bool? b;
bool? c = true;
b = null;
Console.WriteLine(b ?? c);
b = false;
Console.WriteLine(b ?? c);
}
}
在编译和执行之后,我们会发现以下内容:
C:\>csc /nologo test.cs
C:\>test
True
False
第一次(b??c)被求值(b==null),所以表达式的结果就是c的值。第二次(b!=null),那么表达式的结果就是b的值。
在 C++/CLI 中,您可以使用?:三元运算符通过更改以下内容来完成同样的事情:
(b??c)
到
Nullable<bool>( b.HasValue ? b : c );
该语句检查b是否有值,如果有,则使用该值。否则,使用c的值。它不像 C# 版本那样简洁,但是它完成了同样的事情。下面是重做的整个片段:
using namespace System;
ref struct R
{
static void Main()
{
Nullable<bool> b;
Nullable<bool> c = Nullable<bool>(true);
b = Nullable<bool>();
Console::WriteLine(Nullable<bool>( b.HasValue ? b : c ));
b = Nullable<bool>(false);
Console::WriteLine(Nullable<bool>( b.HasValue ? b : c ));
}
};
void main() {R::Main();}
在后台
有趣的是如果你加载。NET Reflector 并检查 IL 中的可空类型,您看不到任何与圆滑的 C# 语法type?或??有丝毫相似之处。相反,您看到的是更类似于 C++/CLI 代码的东西,它显示了作为泛型类实现的Nullable。泛型将在第十四章的中有更详细的解释。事实上,方法Main()的 C# 版本是从的遗留版本反编译成 C++/CLI。网状反射器。和 C++/CLI 版本差不多吧?
private:
static void Main()
{
Nullable<Boolean> nullable2 = Nullable<Boolean>(1) ;
Nullable<Boolean> nullable1 = Nullable<Boolean>();
Nullable<Boolean> nullable3 = nullable1;
Console::WriteLine((nullable3.HasValue ? nullable3 : nullable2));
nullable1 = Nullable<Boolean>(0) ;
nullable3 = nullable1;
Console::WriteLine((nullable3.HasValue ? nullable3 : nullable2));
}
检查表达式
在前面的示例中,我们使用了以下表达式:
int i0 = int::MaxValue;
这个表达式允许我们确定一个整数的最大值。在 C++ 中,有一种非常可疑,但不幸的是有些普遍的方法来做同样的事情。它利用了这样一个事实,即在大多数目标体系结构上,整数以二进制补码存储,整数和无符号整数之间的转换是相同大小的数据类型之间的转换。在这种情况下,int“–1”对应的是最大可能的unsigned int,这个数除以 2,可以让我们计算出最大可能的整数。以下是 C++ 中的表达式:
int i0=(int)(((unsigned)(int)-1)/2);
即使将关键字unsigned改为uint,前面的代码也不会在 C# 中编译。C# 编译器知道你在用这个表达式做一件可怕的事情,它要求你把它嵌入到一个unchecked表达式块中来编译它,如下所示:
class R
{
public static void Main()
{
unchecked
{
int i0 = (int)(((uint)(int)-1)/2);
System.Console.WriteLine(i0);
}
}
}
在用 C# 编译和执行这段代码后,我们得到了以下结果:
C:\>csc /nologo test.cs
C:\>test
2147483647
在 C# 中,有两种方法可以控制表达式的检查:
- 将代码嵌入选中或未选中的块中;这些是以关键字
checked或unchecked开头的块。 - 在命令行上指定
/checked+或/checked-,对未嵌入已检查或未检查块的代码启用或禁用全局表达式检查。
C++ 没有类似于这种类型的检查表达式。
匿名方法
C# 有一个漂亮的语法,使用称为匿名方法的委托来创建嵌套方法。它是为 C# 2.0 添加的,C++ 还没有类似的版本。匿名方法允许您在另一个方法的上下文中动态创建一个方法;它们节省时间,并能使代码更加清晰。允许匿名方法访问包含方法中的局部变量,如下例所示:
using System;
class R
{
public delegate void SayHello(string Message);
SayHello dSayHello;
public static void Main()
{
int Count = 0;
R r = new R();
r.dSayHello += delegate(string Message)
{
Console.WriteLine("{0} : {1} ", ++Count, Message);
};
r.dSayHello("call");
r.dSayHello("call");
}
}
上下文相关的关键字
上下文相关关键字是在特定语法上下文中被解释为关键字的标识符。大多数新的 C++/CLI 关键字都是作为上下文相关的关键字添加的,因此大多数遗留代码都可以在 C++/CLI 下编译,没有任何变化。
例如,考虑下面的代码片段:
void main()
{
int property = 3;
int event = 2;
}
在开发新的 C++/CLI 语法之前,这段代码是有效的 C++,编译器需要正确地编译它。它使用标识符property和event,但是在这个上下文中它们不被认为是关键字,所以这段代码可以为 CLR 编译而不会出错。
上下文敏感的关键字可以产生一些有趣的例子,其中标识符有时是关键字,有时不是。考虑以下有效的 C++/CLI 代码:
value struct property {};
ref struct Test
{
property property property;
};
void main() {}
在这种情况下,我们有一个名为property的普通property,它的类型是property。
方法组转换
C# 有一个注册事件处理程序的缩写语法,称为方法组转换。这为使用事件和委托提供了更简单的语法,如下例所示:
using System;
class R
{
public delegate void SayHello(string Message);
SayHello dSayHello;
int Count = 0;
public void DisplayMessage(string Message)
{
Console.WriteLine("{0} : {1} ", ++Count, Message);
}
public static void Main()
{
R r = new R();
// r.dSayHello = new SayHello(r.DisplayMessage);
r.dSayHello = r.DisplayMessage;
r.dSayHello("call");
r.dSayHello("call");
}
}
注释掉的行可能会被后面的粗体行替换。C++ 没有等价的语法。
这种语法在 C# 语言中很常见。这种语言提供了许多这样的小快捷方式,为用户节省了几行代码,从而加快了用 C# 开发应用程序的速度。
Note
委托和事件代码总是很难翻译,因为诊断可能会产生误导。我们可以将大部分责任归咎于编译器解释上下文相关关键字的能力。这些关键字是在歧义消除语法中确定的,如果该语法断定delegate或event不是该上下文中的关键字,则它们被解释为常规标识符,并且诊断没有多大帮助。
下面是上一个转换为 C++/CLI 的示例,没有使用方法组转换:
using namespace System;
ref struct R
{
delegate void SayHello(String^ Message);
SayHello ^dSayHello;
int Count;
R() : Count(0) {}
void DisplayMessage(String^ Message)
{
Console::WriteLine("{0} : {1} ", ++Count, Message);
}
static void Main()
{
R^ r = gcnew R();
r->dSayHello = gcnew SayHello(r, &R::DisplayMessage);
r->dSayHello("call");
r->dSayHello("call");
}
};
void main() {R::Main();}
构造器初始化的变量
在 C# 中,只能在构造器、构造器成员初始化列表或类声明中初始化的变量是通过标记它们readonly来定义的。在 C++/CLI 中,这些变量是用initonly关键字标记的。
在 C++ 中,只有静态变量可以在类声明中初始化,不管它们是否被标记为initonly。C# 能够创建初始化类变量的隐式实例和静态构造器,而 C++/CLI 只能创建隐式静态构造器。
例如,考虑下面的 C# 代码:
using System;
class R
{
readonly int i0=3;
static readonly int i1=4;
static void Main() {}
}
下面是等效的 C++/CLI:
using namespace System;
ref class R
{
initonly int i0;
static initonly int i1=4;
R()
{
i0=3;
}
public:
static void Main() {}
};
void main() {R::Main();}
编译器隐式创建静态构造器;非静态initonly变量的初始化必须在实例构造器中显式执行。
没有效果的表达式语句
C# 不允许大多数无效的表达式语句。这些在 C++ 中是允许的,因为它们通常对调试有用,或者用作条件预处理器语句的剩余部分。
考虑下面的 C# 代码:
using System;
class R
{
static void Main()
{
int i=3;
(i==2); // invalid in C#
}
}
结果如下:
C:\>csc /nologo test.cs
test.cs(7,9): error CS0201: Only assignment, call, increment, decrement, and new
object expressions can be used as a statement
像(i==2);这样的表达式语句在 C++ 中是有效的语法,编译时既没有错误也没有警告。
例外
在很大程度上,C# 和 C++/CLI 中的异常处理非常相似。C++ 在与 C# 相同的上下文中支持异常,只是增加了一个称为 function-try 块的特殊构造,我们将在本节稍后介绍。
让我们从基础开始。
基本异常处理
下面是 C# 中基本异常处理的一个简短而全面的示例:
using System;
class MyException : Exception
{
public MyException(string message) : base(message)
{
}
}
class R
{
static void Main()
{
try
{
throw new MyException("exception");
}
catch (MyException e)
{
Console.WriteLine("caught : {0}", e);
return;
}
catch
{
}
finally
{
Console.WriteLine("in the finally block");
}
}
}
在这个例子中,我们从定义一个从System::Exception派生而来的名为MyException的定制异常开始。构造器将一个string作为参数,并将其转发给基类。为了将它转换成 C++,我们必须采取以下步骤:
Change class to ref class. Change string to String^. string is an alias that does not exist in C++; C++ invokes the reference type System::String explicitly. Change base to Exception, as C++ refers to the base class constructor explicitly by name. Add a semicolon to the end of the class definition. Add a colon to the public keyword.
然后,我们有以下内容:
ref class MyException : Exception
{
public:
MyException(String ^message) : Exception(message)
{
}
};
接下来,我们对身体进行类似的改变。
因为MyException是一个引用类型,我们需要像对待引用类型一样对待它,并改变它
MyException e
到
MyException ^e
此外,我们需要采取以下步骤:
Change class to ref class. Change new to gcnew. Change Console.WriteLine to Console::WriteLine. Add a semicolon to the end of the class definition. Change the generic catch handler from catch to catch(. . .). The generic catch handler is the last handler that catches all exceptions previously uncaught. The C++ syntax requires the ellipsis. Insert the public: keyword before the Main() function, and add a global main() function to invoke it.
结果代码如下:
using namespace System;
ref class MyException : Exception
{
public:
MyException(String ^message) : Exception(message)
{
}
};
class R
{
public:
static void Main()
{
try
{
throw gcnew MyException("exception");
}
catch (MyException ^e)
{
Console::WriteLine("caught : {0}", e);
return;
}
catch(...)
{
}
finally
{
Console::WriteLine("in the finally block");
}
}
};
void main() {R::Main();}
让我们编译并执行这个:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
caught : MyException: exception
at R.Main()
in the finally block
注意finally块总是在try块的末尾执行,不管是否有异常。这是真的,即使有一个return或break声明可能表面上规避这一机制。事实上,finally总是被执行。
您可能会尝试使用finally语句来控制非托管资源或其他类似应用程序的释放,但是有更好的方法可以做到这一点,从 C# 中的 using 语句到。NET 和 C++。我们将在第二十章中详细介绍这一点。
函数-尝试块
Function-try 块是 C++ 独有的特性,它允许您捕捉整个函数体内发生的任何异常。这个特性可能看起来无关紧要,直到您意识到它允许您在派生类的构造过程中捕获基类构造器中的异常。
问题
让我们看一个例子来说明。在 C# 中,很难在派生类的上下文中捕获基类中生成的异常。它需要在创建派生类对象的方法中被捕获,该方法不在异常的本地。因此,这使得异常处理很成问题。下面是一个带有基类和派生类的 C# 示例:
using System;
class Base
{
public Base(int i)
{
throw new Exception("throwing in Base's constructor");
}
}
class Derived : Base
{
Derived(int i) : base(i)
{
}
static void Main()
{
Derived r = new Derived(3);
}
}
让我们试着编译并运行这个:
C:\>csc /nologo test.cs
C:\>test
Unhandled Exception: System.Exception: throwing in Base's constructor
at Base..ctor(Int32 i)
at Derived.Main()
进入函数-Try 块
在 C++ 中,您可以使用 function-try 块捕获这个异常。function-try 块捕获发生在函数体中任何地方的异常,应用于构造器时,还捕获基类构造器中引发的异常。以下是构造器上 function-try 块的语法:
Derived(int i)
try
: Base(i)
{
}
catch(Exception ^e)
{
}
有点不一样,但是很管用。
因为我们在构造中抛出了一个异常,所以在这个序列的末尾有一个隐式的重新抛出来通知试图创建一个Derived实例的方法,就像在 C# 中一样。我们可以让这种重新抛出发生,并在顶层捕获它,或者我们可以改变异常的类型,以表明它是在更早的时候被捕获的。下面是一个完整的示例,其中重新引发了一种不同类型的异常:
using namespace System;
ref class MyException : Exception
{
public:
MyException(String ^message) : Exception(message)
{
}
};
ref class Base
{
public:
Base(int i)
{
throw gcnew Exception("throwing in Base's constructor");
}
};
ref class Derived : Base
{
Derived(int i)
try
: Base(i)
{
}
catch(Exception ^e)
{
Console::WriteLine("caught {0}", e);
throw gcnew MyException("caught");
}
public:
static void Main()
{
try
{
Derived ^r = gcnew Derived(3);
}
catch(Exception ^e)
{
Console::WriteLine("caught {0}", e);
}
}
};
void main() {Derived::Main();}
在这个序列中,我们使用 function-try 块捕获基类构造中抛出的异常,然后在 catch 子句中抛出一个不同类型的异常。
以下是代码编译和执行时的结果:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
caught System.Exception: throwing in Base's constructor
at Base..ctor(Int32 i)
at Derived..ctor(Int32 i)
caught MyException: caught
at Derived..ctor(Int32 i)
at Derived.Main()
EXERCISE
异常处理的复杂性往往会迷惑反编译器。编译这个示例,并使用。网状反射器。IL 看起来怎么样?它是如何反编译成 C# 的?
摘要
在这一章中,我们完成了基本 C++ 的旅程。我们讨论了包含文件、范围解析、各种操作符细节和异常。在接下来的章节中,我们将更深入地探索 C++/CLI 和原生 C++,并学习实际例子和更高级的结构。
我认为将这一探索推迟一章,在第十三章短暂休息一下,做几个常见的面试问题会很有趣。
Footnotes 1
本机 C++ 异常处理可能会变得复杂,因为编译器支持多种变体。有关详细信息,请参考 Visual C++ 文档。