3.12 技术解惑
C#功能强大,使用基于C#语言的ASP.NET可以开发出功能强大的动态Web站点。正是因为如此,所以一直深受广大程序员的喜爱。作为一名初学者,肯定会在学习过程中遇到很多疑问和困惑。为此在本节的内容中,笔者将自己的心得体会传授大家,帮助读者解决困惑和一些深层次性的问题。
3.12.1 代码缩进的意义
缩进可以提高代码的可读性,但是很多程序员对此不以为然,自认为自己水平够高,在编程时往往忽视代码缩进。其实代码缩进主要是为维护人员服务的,开发人员确实熟悉自己编写的代码,但是当项目完成后,还有大量的维护人员作为后来者继续让项目维持下去。为了让维护人员以更快的效率了解代码的含义,在此建议程序员养成代码缩进的习惯。关于代码的缩进问题,在此向读者提出如下2点建议。
(1)独立语句独立代码行。虽然C#中允许在同行内放置多个C#语句,但是为了提高代码的可读性,建议每个语句放置在独立的代码行中,即每代码行都以分号结束。
(2)代码缩进处理。对程序内的每个代码块都设置独立的缩进原则,使各代码块在整个程序中以更加清晰的效果展现出来。令程序员比较兴奋的事情是,在使用Visual Studio进行C#开发时,Visual Studio能够自动地实现代码缩进。
3.12.2 变量是否必须初始化
变量初始化问题十分重要,可能有的读者有过其他语言的开发经验,在很多要求不高的语言中,允许不对变量进行初始化。但是在使用C#变量时,必须遵循在使用前进行初始化处理的规则。况且初始化的工作量不大,我们何乐而不为呢?例如,下面的首行代码进行了初始化,而第二行代码进行了赋值。
int age;
age=22;
而在下面的代码中,没有对变量name进行初始化,所以执行后会出现编译错误。
static void Main(string[] args)
{
string name;
string myString;
name = myString
Console.WriteLine("{0} {1}", myString, name);
Console.ReadKey();
}
3.12.3 常量和变量的区别
常量和变量很好区别,就像人们常说的“花心男人”和“专一男人”一样,常量的值很“专一”,而变量的值比较“花心”。声明常量时必须初始化,当初始化指定其值后,就不能再修改了。另外,要求常量的值必须能在编译时用于计算。因此,不能用从一个变量中提取的值来初始化常量。如果需要这么做,应使用只读字。
虽然常量总是静态的,但是不必(实际上是不允许)在常量声明中使用static修饰符。在程序中使用常量至少有如下3个好处。
(1)常量用易于理解的清除的、名称替代了“含义不明确的数字或字符串”;
(2)使程序更易于阅读。
(3)常量使程序更易于修改。
另外,在编程应用中经常遇到“常数”这一概念,接下来将简要剖析“常数”和“常量”的区别。
常量是编程语言中的一个概念,而常数是数学中的一个概念,二者从属不同的领域,故彼此没有任何联系。例如下面的代码:
const double pi =3.1415926
称为常量也可以,称为常数也可以,这只是自己的一种口语。再看下面的代码:
const double string txt="苦咖啡"
如果这时你还称为常数那就错了,因为“苦咖啡“是一个字符串,而不是一个数字。
3.12.4 为什么使用类型转换
在计算机中,所有的数据都是由0和1构成的一系列位。C#中最简单的类型是char,它可以用一个数字来表示Unicode字符集中的一个字符。在默认情况下,不同类型的变量使用不同的模式来表示数据。这意味着变量值经过移位处理后,所得到的结果将会不同。当在项目中实现复杂功能的时候,通常会使用多种数据类型来实现,这就需要类型转换来解决上述问题。
另外,C#中不存在char类型的隐式转换,所以其他整型值不会自动转换为char类型。另外,读者不需要强记表2-5的内容,只需牢记各类型的取值范围即可。因为对于任何类型A,只要其取值范围被完全包含在类型B的取值范围内,就可以隐式地将类型A转换为类型B。
3.12.5 避免分配额外的内存空间
对CLR来说,string对象(字符串对象)是个很特殊的对象,它一旦被赋值就不可改变。无论是在运行时调用类System.String中的任何方法,还是进行任何运算(如“=”赋值、“+”拼接等),都会在内存中创建一个新的字符串对象,这也意味着要为该新对象分配新的内存空间。例如,下面的代码就会带来运行时的额外开销。
private static void NewMethod1()
{
string s1 = "abc";
s1 = "123" + s1 + "456"; //以上两行代码创建了3个
//字符串对象,并执行了一次string.Contact方法
}
private static void NewMethod6()
{
string re6 = 9 + "456"; //该行代码发生一次装箱,并调
//用一次string.Contact方法
}
而在以下代码中,字符串不会在运行时拼接字符串,而是会在编译时直接生成一个字符串。
private static void NewMethod2()
{
string re2 = "123" + "abc" + "456"; //该行代码等效于
//string re2 = "123abc456";
}
private static void NewMethod9()
{
const string a = "t";
string re1 = "abc" + a; //因为a是一个常量,所以
//该行代码等效于 string re1 = "abc" + "t";
//最终等效于string re1 = "abct";
}
由于使用类System.String会在某些场合带来明显的性能损耗,所以微软公司另外提供了一个类型:StringBuilder,用于弥补String的不足。
StringBuilder并不会重新创建一个string对象,它的效率源于预先以非托管的方式分配内存。如果StringBuilder没有预先定义长度,则默认分配的长度为16。当 StringBuilder字符长度小于等于16时,StringBuilder不会重新分配内存;当StringBuilder字符长度大于16小于32时,StringBuilder又会重新分配内存,使之成为16的倍数。在上面的代码中,如果预先判断字符串的长度大于16,则可以为其设定一个更加合适的长度(如32)。StringBuilder重新分配内存时是按照上次的容量加倍进行分配的。读者需要注意,StringBuilder指定的长度要合适。如果太小,则需要频繁分配内存;如果太大,则会浪费空间。
3.12.6 在编程中要确保尽量少的装箱
字符串是所有编程语言中使用最频繁的一种基础数据类型。如果在使用时稍有不慎,就会为一次字符串的操作所带来的额外性能开销而付出代价。笔者在此建议在程序中要确保尽量少的装箱。例如,下面的两行代码。
String str1 = "str1"+ 9;
String str2 = "str2"+ 9.ToString();
为了清楚上述两行代码的执行情况,接下来比较两者生成的IL代码。其中第一行代码对应的IL代码如下。
.maxstack 8
IL_0000: ldstr "str1"
IL_0005: ldc.i4.s 9
IL_0007: box [mscorlib]System.Int32
IL_000c: call string [mscorlib]System.String::Concat(object, object)
IL_0011: pop
IL_0012: ret
第二行代码对应的IL代码如下。
.maxstack 2
.locals init ([0] int32 CS$0$0000)
IL_0000: ldstr "str2"
IL_0005: ldc.i4.s 9
IL_0007: stloc.0
IL_0008: ldloca.s CS$0$0000
IL_000a: call instance string [mscorlib]System.Int32::ToString()
IL_000f: call string [mscorlib]System.String::Concat(string, string)
IL_0014: pop
IL_0015: ret
由此可以看出,第一行代码“str1”+ 9在运行时会完成一次装箱行为(IL代码中的box);而第二行代码中的9.ToString()并没有发生装箱行为,它实际调用的是整型的ToString方法。方法ToString的原型如下。
public override String ToString()
{
return Number.FormatInt32(m_value, null, NumberFormatInfo.CurrentInfo);
}
可能有人会问,是不是原型中的Number.FormatInt32方法会发生装箱行为呢?实际上,Number.FormatInt32方法是一个非托管的方法,此方法的原型如下。
[MethodImpl(MethodImplOptions.InternalCall), SecurityCritical]
public static extern string FormatInt32(int value, string format,
NumberFormatInfo info);
此方法通过直接操作内存来完成从int到string的转换,效率要比装箱高很多。所以,在使用其他值引用类型到字符串的转换并完成拼接时,应当避免使用操作符“+”来完成,而应使用值引用类型提供的ToString方法。也许有的读者还会问:上文所举的示例中,即使FCL提供的方法没有发生装箱行为,但在其他情况下,FCL方法内部会不会含有装箱的行为呢?答案是也许会存在。不过在此有一个指导原则:在自己编写的代码中,应当尽可能地避免编写不必要的装箱代码。
装箱之所以会带来性能损耗,因为它需要经历下面3个步骤。
(1)为值类型在托管堆中分配内存。除了值类型本身所分配的内存外,内存总量还要加上类型对象指针和同步块索引所占用的内存。
(2)将值类型的值复制到新分配的堆内存中。
(3)返回已经成为引用类型的对象的地址。
3.12.7 使用语句的几种限制
C#中的switch…case语句较C和C++中的更安全,因为它禁止所有case中的失败条件。如果激活了块中靠前的一个case子句,后面的case子句就不会被激活,除非使用goto语句特别标记要激活后面的case子句。编译器会把没有break语句的每个case子句标记为错误,例如下面的错误信息。
Control cannot fall through from one case label ('case 2:') to another
在有限的几种情况下,这种错误是允许的,但在大多数情况下,我们不希望出现这种错误,因为这会导致出现很难察觉的逻辑错误。
但在使用goto语句时,会在switch…cases中重复出现错误。如果确实想这么做,就应重新考虑设计方案了。例如,下面的代码在使用goto时出现错误,得到的代码非常混乱。
switch(country)
{
case "America":
CallAmericanOnlyMethod();
goto case "Britain";
case "France":
language = "French";
break;
case "Britain":
language = "English";
break;
}
但此时还有一种例外情况:如果一个case子句为空,就可以直接跳到下一个case子句,这样就可以用相同的方式处理两个或多个case子句了(不需要goto语句)。例如下面的代码。
switch(country)
{
case "au":
case "uk":
case "us":
language = "English";
break;
case "at":
case "de":
language = "German";
break;
}
在C#中,switch语句的case子句的排放顺序是无关紧要的,甚至可以把default子句放在最前面。因此,任何两个case都不能相同。这包括值相同的不同常量,所以不能这样编写。
const string england = "uk";
const string britain = "uk";
switch(country)
{
case england:
case britain: // this will cause a compilation error
language = "English";
break;
}
上述代码还说明了C#中的switch语句与C++中的switch语句的另一个不同之处:在C#中,可以把字符串用作测试变量。
3.12.8 带/不带表达式的return语句
return语句后面可以紧跟一个可选的表达式,不带任何表达式的return语句只能被用在没有返回值的函数中。因此,不带表达式的return语句只能被用于返回类型为如下类别的对象中:
(1)返回类型是void的方法。
(2)属性和索引器中的set访问器中。
(3)事件中的add和remove访问器。
(4)实例构造函数。
(5)静态构造函数。
(6)析构函数。
而带表达式的return语句只能被用于有返回值的类型中,即返回类型为如下类别的对象中。
(1)返回类型不是void的方法。
(2)属性和索引器中的get访问器或用户自定义的运算符。
另外,return语句的表达式类型必须能够被隐式地转换为包含它的函数成员的返回类型。
共有条评论 网友评论