条件结构

我们前面用了六个文章介绍了运算符的使用。今天我们进入新的板块:程序控制结构。C 语言里,程序控制结构一共有四种:顺序结构、条件结构、循环结构和跳转结构。C# 里一共是 5 种:顺序结构、条件结构、循环结构、跳转结构和异常控制结构。顺序结构是最基础的结构,在前面的文章早就体现过了,只是没有提出它的名称。只要一段代码从上到下依次执行的过程,我们就把这段代码称为顺序结构。

Part 1 if 的用法

1-1 if 语句

前面的内容我们无法控制代码在指定条件的时候,才做一些工作。当我们需要这样的用法的话,我们将使用 if 语句来控制。

格式是这样的:

  1. if 语句
  2. 'if' '(' 条件表达式 ')' '{' 条件成立的时候的代码段 '}'
  3. 'if' '(' 条件表达式 ')' '条件成立的时候的一个执行语句' ';'

有这样两种写法。我们来写一个例子。

  1. int a = 30, b = 40;
  2. if (a > b)
  3. {
  4. Console.WriteLine("a is greater than b.");
  5. }

在这个例子里,我们在 if (a > b) 之后使用大括号,将 Console.WriteLine 完整包裹起来。这表示,用大括号包裹起来的所有内容,都属于“当 a > b 成立的时候,才会执行的逻辑”。
当 a > b 的结果是 false(即不成立)的时候,不管大括号里有多少东西,都不会得到执行,会被完全忽视掉。显然,这个例子里,30 是比 40 小的,所以条件并不成立。因此,你在执行过程之中,什么输出都看不到。

顺带一说,既然用到了大括号,那么里面的内容就需要缩进来保证代码的整洁。如果大括号内的内容不缩进的话,看起来就很奇怪。当然,它并不影响编译程序和运行程序就是了。

1-2 else 语句

显然,这样的格式并不能满足我们正常的需求。前文的例子我们其实还想看到另外一个输出内容,“b 比 a 大”,但在代码里没有体现。因此,else 就派上用场了。
和 if 差不多,else 后也是跟一个大括号,或者一个单独的语句。这部分表示在 if 条件并不成立的时候,才会得到执行。

int a = 30, b = 40;
if (a > b)
    Console.WriteLine("a is greater than b.");
else
    Console.WriteLine("b is greater than a.");

如果我们这么写代码的话,这就表示,当 if (a > b) 里的 a > b 条件成立的时候,会输出 a 比 b 大的内容;但是如果不成立,就会输出 b 大于 a 的内容。
从这个介绍文字就可以看出,它们是完全对立的两种情况,所以 if-else 整体的话,if 的部分和 else 的部分并不会都执行,也必然必须执行至少一个部分。这就是 if-else 配合一起使用的时候的特点。

当然,前文说过,空格是不影响编译和运行程序的,因此我们甚至可以写在一行:

int a = 30, b = 40;
if (a > b) Console.WriteLine("a is greater than b.");
else Console.WriteLine("b is greater than a.");

这么写依然是没有问题的。代码写成什么样看的是个人的习惯,只要逻辑没有问题就行。

1-3 else 后也不一定非得是大括号和语句

前面的示例,可能我们漏掉了一个地方。a > b 的对立情况就一定是 b > a 吗?当然不是,还可以相等。但是我们刚才没有体现这一点。下面我们来试着添加这个部分:

int a = 30, b = 40;
if (a > b)
    Console.WriteLine("a is greater than b.");
else
{
    if (a == b)
        Console.WriteLine("a equals to b.");
    else
        Console.WriteLine("a is less than b.");
}

当 a > b 条件不成立的时候,我们会想着走 else 这一段代码。而 else 里又是一个 if 判断:a == b 的条件。所以实际上就是这么一个过程:我们先看 a > b 条件是不是成立的。如果不成立,就会继续判断 a == b 这个条件。因此,实际上这种写法就解决了前面说的,忘记判断 a == b 的问题。
不过,这个写法还是有点丑。C# 可以允许我们去掉 else 的大括号

int a = 30, b = 40;
if (a > b)
    Console.WriteLine("a is greater than b.");
else
    if (a == b)
        Console.WriteLine("a equals to b.");
    else
        Console.WriteLine("a is less than b.");

这个写法是没有问题的。C# 是知道,下方的 if-else 是一个整体,因此不需要大括号就知道整个代码里的 else 就完整包含了下面一大坨内容。但是,实际上还是有点丑。不如我们把 else 后直接跟上 if:

int a = 30, b = 40;
if (a > b)
    Console.WriteLine("a is greater than b.");
else if (a == b)
    Console.WriteLine("a equals to b.");
else
    Console.WriteLine("a is less than b.");

这样就很好了。从这个角度来说,我们一下就可以看出逻辑:先 a > b;如果不满足就 a == b;再不满足就执行最下面的 else 的部分。

1-4 它和条件运算符的关联

最开始,我们说到过一个东西,叫条件运算符。条件运算符的逻辑完全类似于这里的 if-else 的过程。不过问题在于,它们有区别吗?
条件运算符用来表示和赋值,因此 ? 后和 : 后的部分全都是一个表达式(所谓的表达式,就必须反馈一个结果数值出来;而语句则不一定要反馈结果,它可以像是 Console.WriteLine 那样,单独成一个语句来用)。而 if-else 则可以跟语句或者是赋值表达式。我们来看一下。

int a = 3, b = 4, c;
c = a > b ? a : b;

这是将 a 和 b 较大的数值赋值给 c 的过程。如果要写成 if-else,就得这样:

int a = 3, b = 4, c;
if (a > b)
    c = a;
else
    c = b;

不管怎么做,代码都比条件运算符要复杂。因此,我们建议用条件运算符而不是用 if-else,尽管逻辑上是完全一样的。

1-5 永真和永假条件

有没有想过,既然这里我们传入的是一个 bool 的表达式,那么我们直接写 true 和 false 会如何。

if (true)
{
    // ...
}

或者

if (false)
{
    // ...
}

猜都猜得到,只要条件是 true 就会进去执行。因此 if (true) 等价于根本没写条件判断。而 if (false) 则判断了个寂寞。永远都不成立,那么 if 里面的语句永远都得不到执行。

Part 2 switch 的用法

很显然,前文的 if-else 是无敌的。但是很遗憾的是,对于一些特殊的条件判断,if-else 怎么写都很臃肿。比如我们输入一个月份数值,然后判断月份到底有多少天。那么用 if 就得这样:

int month, day;
string suffix;

// Input a value.
Console.WriteLine("Please input the month value (1-12):");
month = int.Parse(Console.ReadLine());

// Check the month, and get the number of days.
if (month == 1) day = 31;
else if (month == 2) day = 28; // Suppose the year isn't a leap year.
else if (month == 3) day = 31;
else if (month == 4) day = 30;
else if (month == 5) day = 31;
else if (month == 6) day = 30;
else if (month == 7) day = 31;
else if (month == 8) day = 31;
else if (month == 9) day = 30;
else if (month == 10) day = 31;
else if (month == 11) day = 30;
else if (month == 12) day = 31;
else day = 0;

// Get the suffix.
if (month == 1) suffix = "st";
else if (month == 2) suffix = "nd";
else if (month == 3) suffix = "rd";
else suffix = "th";

// Output the result.
if (day == 0) Console.WriteLine("Your input is invalid.");
else Console.WriteLine("The {0}{1} month contains {2} days.", month, suffix, day);

我且不说其它的内容,就判断 month 的信息,也显得很臃肿。此时,我们这里介绍一个新的语法格式:switch 语句。

2-1 switch 语句

switch 语句的用法是这样的:

switch 语句
    'switch' '(' 变量或表达式 ')' '{' 分情况讨论的语句* '}'

分情况讨论的语句
    情况标签 (一段语句 'break' ';')?

情况标签
    'case' 变量的可能数值 ':'
    'default' ':'

这个写法可能不是很好懂。我们直接上代码:

int month, day;
string suffix;

// Input a value.
Console.WriteLine("Please input the month value (1-12):");
month = int.Parse(Console.ReadLine());

// Check the month, and get the number of days.
switch (month)
{
    case 1: day = 31; break;
    case 2: day = 28; break; // Suppose the year isn't a leap year.
    case 3: day = 31; break;
    case 4: day = 30; break;
    case 5: day = 31; break;
    case 6: day = 30; break;
    case 7: day = 31; break;
    case 8: day = 31; break;
    case 9: day = 30; break;
    case 10: day = 31; break;
    case 11: day = 30; break;
    case 12: day = 31; break;
    default: day = 0; break;
}

// Get the suffix.
if (month == 1) suffix = "st";
else if (month == 2) suffix = "nd";
else if (month == 3) suffix = "rd";
else suffix = "th";

// Output the result.
if (day == 0) Console.WriteLine("Your input is invalid.");
else Console.WriteLine("The {0}{1} month contains {2} days.", month, suffix, day);

貌似……好像代码没有减少多少。不过更好看了,因为 month 直接写到 switch 上用来表示“我这里就是按 month 自身的数值进行分情况讨论”。case 里写的就是 month 的所有可能数值。default 则表示当前面所有数值都不是 month 现在的数值的时候,就走这里。

请注意一下,每一个 case 语句最后,都要跟一个 break;,这是因为这是为了让每一个 case 断层。C 语言里,没有写 break 就会导致执行的时候出现潜在的问题。C# 沿用了这种机制,但防止你误用代码,所以不写 break 会产生编译错误,提示你必须加了 break; 之后才能继续执行。C# 里,break; 语句遇到后,会自动跳出 switch 的内容。比如这个例子里,会自动跳转到第 27 行代码执行。

2-2 同执行语句简化

显然,我们发现当 1、3、5、7、8、10、12 月份的时候,day 都是 31;而当 4、6、9、11 的时候,day 都是 30。switch 还有一个强劲的功能就是,简化 case。我们只看 switch 这一块,代码可以简化成这样:

switch (month)
{
    case 1: case 3: case 5: case 7: case 8: case 10: case 12:
        day = 31; break;
    case 4: case 6: case 9: case 11:
        day = 30; break;
    case 2:
        day = 28; break; // Suppose the year isn't a leap year.
    default:
        day = 0; break;
}

这样的话,就更简单一些了。如果写 if 的话,可能你需要用 || 来连接条件。比如说第 3 行转成 if 就得写成这样:

if (
    month == 1 || month == 3 || month == 5 || month == 7
    || month == 8 || month == 10 || month == 12
)
{
    day = 31;
}

显然,就很丑。

2-3 字符串的 switch

在 C# 里,因为有字符串这种类型,因此 switch 还可以对字符串进行判断。

string name;

Console.WriteLine("Please input a fruit name:");
name = Console.ReadLine();

switch (name)
{
    case "Apple": case "apple":
        Console.WriteLine("I like apple very much!"); break;
    case "Banana": case "banana":
        Console.WriteLine("I don't like banana."); break;
    case "Orange": case "orange":
        Console.WriteLine("The color of orange is beautiful!"); break;
    case "Pear": case "pear":
        Console.WriteLine("Too sweet! I like it!"); break;
    default:
        Console.WriteLine("I don't like other fruits."); break;
}

当然,这只是一个例子程序。不过这一点我们用到了 case 字符串 的格式。确实,C# 也是允许这么做的。
由于字符串的每一个字符都不一样的关系,就算只有大小写不同的两个字符串也属于两个不同的字符串,因此我们这里在例子程序中是分开成两个情况作判断的。

2-4 很遗憾,布尔量没有 switch 一说

这显然是废话。bool 类型的数值往往只有 true 和 false 两种可选情况,那么你要写成 switch 语句的话,你只能写成这样:

switch (condition)
{
    case true:
        // Content here.
        break;
    case false:
        // Content here.
        break;
}

可问题在于,case true 和 case false 就已经构成了 bool 类型的所有可能取值的情况。那么,我是不是就意味着我可以把这里的 case false 替代为 default 呢?那么,我这么写和前文的语义是一样的吗?

switch (condition)
{
    case true:
        // Content here.
        break;
    default:
        // Content here.
        break;
}

如果真的是一样的,那么我这么写代码不就很奇怪了吗?

switch (condition)
{
    case true:
        // Content here.
        break;
    case false:
        // Content here.
        break;
    default: // Weird.
        // Content here.
        break;
}

是的。考虑到这种写法的语义格式的复杂性,以及使用场景的问题,C# 并未对布尔型变量开放允许使用 switch 语句。虽然很遗憾,但也是合情合理的情况。

Part 3 混用 switch 和 if

前文我们没有判断 2 月份在平年还是闰年。如果我们加上这个判断的话,就这么写:

switch (month)
{
    case 1: case 3: case 5: case 7: case 8: case 10: case 12:
        day = 31; break;
    case 4: case 6: case 9: case 11:
        day = 30; break;
    case 2:
        if (year % 400 == 0 || year % 4 == 0 && year % 100 != 0)
            day = 29;
        else
            day = 28;
        break;
    default:
        day = 0; break;
}
但仍请注意,case 最后必须跟一个 break;,因此就算我们写了一大坨东西,最后的 break; 也是不可少的。当然了,你也可以这么写:

switch (month)
{
    case 1: case 3: case 5: case 7: case 8: case 10: case 12:
        day = 31; break;
    case 4: case 6: case 9: case 11:
        day = 30; break;
    case 2:
        day = year % 400 == 0 || year % 4 == 0 && year % 100 != 0 ? 29 : 28;
        break;
    default:
        day = 0; break;
}

这么写呢,就是长一点,但是用的是条件运算符。

Part 4 if 和 switch 的选择

如果你在写代码的时候,肯定会遇到“我到底用 if 好,还是 switch 好”的问题。有一个很好的判断标准是,只要你要对一个单独的数值进行判断的,就用 switch;否则都用 if。
比如前面的例子,我们对 month 进行数值的确认,显然是用 switch 更合理;但是如果是其它的情况,我们都应该采用 if 来表示。当然了,如果可能数值过多的话,我们就不建议用 switch 了。

循环结构

如果我们重复执行特别多相同或相似的操作,如果使用一句一句代码来书写的话,显然是会使得程序臃肿的,而且还麻烦。于是,循环结构就诞生了。
C# 里提供了四种循环结构语句:while 语句、do-while 语句、for 循环和 foreach 循环。下面我们挨个进行介绍。
foreach 循环我们不在这里介绍。因为它需要用到一个新的东西,叫做数组(Array)。所以本节内容我们只讲前面三种循环语句

Part 1 while 语句

最基础的语句就是 while 语句。while 语句的格式是这样的: 、

while 语句
    'while' '(' 条件 ')' 循环体

循环体
    语句 ';'
    '{' 代码块 '}'

和 if 写法基本上是一样的,只是 if 改成了 while。比如说,我们要计算从 1 到 100 的和,代码可以这么写:

int i = 1, sum = 0;
while (i <= 100)
{
    sum = sum + i;
    i++;
}

Console.WriteLine(sum);

while 和 if 的格式完全就只有关键字不一样,其它地方写法都是一样的。它的执行逻辑是这样的:我们首先从 i <= 100 这里开始判断。由于 i 从 1 开始,所以条件必然是成立的。按照条件要求,大括号里的代码就会执行一遍。因此,sum = sum + i 和 i++ 都会执行一次。执行完毕后,会返回到 while 循环的条件(i <= 100)处再次判断条件。由于 i 此时执行了一次 i++ 变成了 2,所以和原来的数据不一样了。但是,2 依旧是满足 i <= 100 的条件的,于是又会进来执行 sum = sum + i 和 i++。在反复执行到 i 超过 100 的时候,while 循环就不再执行了,直接到第 8 行,输出 sum 的结果。

如果你是初学编程的话,可能看起来会有点吃力。但是从逻辑上来说,你可以知道一点。循环体(就是大括号这一坨代码)里,i 始终是一个单位一个单位地在增大。那么,从 i 最开始的 1 开始,就会在内部反复执行到 101 才结束。为什么是 101 呢?因为条件 i <= 100 是到 100 都满足的,因此第一个不满足的数值应该是 101 才对。 另外,虽然到 101 才是对的,但内部循环体始终执行的次数是 100 次,即 i 是 1 的时候执行一次,是 2 的时候执行一次,3 的时候执行一次……直到 i 是 100 的时候,还会执行一次。这一共是 100 次(特别需要注意的是,从 1 到 100 是一百次,不是 99 次)。 最后,每一次执行,都会执行 sum = sum + i 这个规程,而每次都是记录了 i 当前的最新数据(1、2、3、4 等等),因此 sum 最终的结果一定是累计从 1 到 100 的总和。所以程序是没有问题的。

下面我们回头来看。sum = sum + i 可以改成 sum += i,而 i++ 写在前面那个语句 sum = sum + i 之后,且这个语句里也用到了 i,所以我们还可以把 i++ 合并到前面那个语句里。所以,循环体甚至可以简化成这样:

int i = 1, sum = 0;
while (i <= 100)
{
    sum += i++;
}

Console.WriteLine(sum);

当然了。大括号只有这么一个语句的话,按照道理是可以不写大括号的。所以,第 3、5 行的大括号是可以不要的。

int i = 1, sum = 0;
while (i <= 100)
    sum += i++;

Console.WriteLine(sum);

这样代码就会少很多。
思考一个问题。请问,如下的代码依旧是计算从 1 到 100 的和。请问这样写代码是否正确:

int i = 0, sum = 0;
while (++i <= 100)
    sum += i;

Console.WriteLine(sum);

Part 2 do-while 语句

由于有些时候,我们没有必要让用户最开始就判断数据是否需要循环,因此 C# 提供了一种后置条件的循环语句:do-while。它的格式有点别扭:

int i = 1, sum = 0;
do
{
    sum += i;
}
while (++i <= 100);

Console.WriteLine(sum);

do 关键字就是为了起到一个占位的作用,用来框定循环体范围,没别的用。这种循环不论如何都会先执行一遍。执行完毕后,再来看条件是不是成立。注意,这里的条件写的是 ++i <= 100,所以注意先执行 ++i,然后才是条件判断。
可以看出,++i 这个语法格式是比较重要的。如果没有它的话,我们不论如何都无法做到在循环体执行完毕后立刻先让 i 增大一个单位,然后才是判断循环。不过确实,你也可以把 i++ 写进循环里,不过逻辑嘛,你就自己理解了。
顺带一提,循环体只有一个语句的时候,大括号是可以省略的。

int i = 1, sum = 0;
do
    sum += i;
while (++i <= 100);

Console.WriteLine(sum);

你可以这么写。当然,再浪一点的话,你还可以这么写:

int i = 1, sum = 0;
do sum += i; while (++i <= 100);
Console.WriteLine(sum);

只是一定要注意,do-while 是一个整体形式的语句。因此在最后需要加分号。在之前的 if、switch 和 while 这些东西的最后都不加分号,这是因为它们要么本身就已经以分号的语句结尾,要么大括号结尾。而 do-while 语句的特殊性,最后一个地方写的是条件表达式,因此一定要加分号。

Part 3 for 语句

最舒服的语法还得是 for。前面两种循环我们都大概看了一下,应该没有大问题。不过 for 循环语句可能难度偏大一些,因为它的写法很灵活。

3-1 标准格式

如果我们改写一下从 1 到 100 的和,用 for 循环的话,我们可以这么写:

int sum = 0;
for (int i = 1; i <= 100; i++)
{
    sum += i;
}

Console.WriteLine(sum);

for 的格式是这样的:

for 循环
    'for' '(' 初始赋值语句 ';' 循环条件 ';' 循环体执行完毕后的增量 ')' 循环体

循环体
    语句 ';'
    '{' 代码块 '}'

这里就需要稍微注意一下,for 后面的小括号里是用分号分隔的每一组数据。从语义上理解的话,我们可以认为 for 循环是一种迭代循环。所谓的迭代,就是让一个实体对象作为基础参考,一直追踪它自身变化的过程。每次循环完毕后,这个东西都会变化,然后将变化后的结果继续带入下一次的运算过程。
比如说,我们完全可以只看 i 来找出变化过程。i 是从 1 到 100,一次增大一个单位,因此在小括号里我们写的是 int i = 1; i <= 100; i++。最左边的是初始赋值语句,表示 i 从多少开始;中间这个写变量应该在什么范围区间里不断循环。显然这里我们需要写的是 i <= 100,因为这样才能表示 i 在 100 以内都要反复执行循环。最后一个部分写的是 i++,这正好对应了循环体执行完毕后自动增大一个单位的过程。最后,在循环体里,我们只需要关心 sum 的变化即可。这就是 for 循环的语义理解方式。

3-2 缺省迭代

在循环的时候,我们有时候不一定非得要写一些东西。如果我们需要把初始赋值提出来的话,我们可以这么搞:

int i = 1, sum = 0;
for (; i <= 100; i++)
{
    sum += i;
}

如果我们想把增量写到循环体里,可以这么搞:

int i = 1, sum = 0;
for (int i = 1; i <= 100; )
{
    sum += i++;
}

你甚至可以省略两部分:

int i = 1, sum = 0;
for (; i <= 100; )
{
    sum += i++;
}

你还可以玩骚操作,把循环体当增量写进增量里面去:

int i = 1, sum = 0;
for (; i <= 100; sum += i++)
{
}

大括号都没东西了,干脆改成空语句(Null Statement)吧。

int i = 1, sum = 0;
for (; i <= 100; sum += i++) ;

空语句就是一个以分号结尾的语句,这个语句里啥都不写,就写成一个分号 ;。这种语句本来是毫无意义的写法,但是为了保证 for 循环这种循环体完整,我们追加一个空语句来表示和限定 for 的执行范围,保证编译器分析代码的时候不出问题。

3-3 复合迭代

在迭代的过程之中,for 循环还允许我们使用多个变量同时迭代。

for (int i = 1, j = 1; i * j < 1000; i++, j += 2)
{
    // ...
}

比如这个例子里,i 和 j 变量同时声明到循环的小括号里。这个表示 i 和 j 同时赋初始数值;而后面的 i++ 和 j += 2 是同时充当增量。在执行过程之中,i++ 先执行,然后就是 j += 2。
从这个角度来说,for 循环允许我们添加多个变量的迭代过程,因此 C# 使用的是逗号分隔每一个迭代变量;而每一个部分使用分号分隔。

Part 4 死循环

既然 if 可以写永真条件,那么 while 这些循环呢?是的,我们完全可以这么做。

while (true)
{
    // ...
}

这种条件是 true 的永真式,我们就把这种循环叫做死循环(Dead Loop)。死循环是永远都不会自动退出的,只要它从上到下执行的时候遇到这种死循环了,就不会主动退出来。
我们不提倡也不支持任何没有意义的死循环,因为它只能卡死程序,没别的用处。

Part 5 特殊循环控制语句

为了帮助辅助使用循环语句,我们可以在循环里插入一些特殊的语句,来灵活控制执行过程。

5-1 break 语句

这个单词是不是很熟悉?是的,就是之前 switch 语句最后跟的那个东西。它还有一个作用就是使得循环体的代码不再执行,自动跳出循环。

for (int i = 1; i <= 5; i++)
{
    if (i % 3 == 0)
    {
        break;
    }

    Console.WriteLine(i);
}

for 循环里嵌入了一个 if 语句,这表示每一次循环体开始执行的时候,都执行判断,是否 i % 3 == 0。由于 i 从 1 到 5,因此会在判断之后,执行输出语句。一旦遇到 i % 3 == 0 条件成立的时候,就会自动执行 break 语句,整个 for 循环后面的代码都不再执行了,直接跳出整个循环。

5-2 continue 语句

和 break 语句相反,continue 语句是反过来的。它表示直接跳转到循环条件判断或增量的地方去执行。我们拿两个例子来举例:

// While loop.
int i = 0;
while (++i <= 100)
{
    if (i % 3 == 0)
    {
        continue;
    }

    Console.WriteLine(i);
}

// For loop.
for (int i = 1; i <= 100; i++)
{
    if (i % 3 == 0)
    {
        continue;
    }

    Console.WriteLine(i);
}

一旦遇到 continue 语句,就会自动跳转到循环的条件判断,或者是增量处。比如说 while 循环,遇到 continue 语句的时候,就会自动跳转到 ++i <= 100;而后者 for 循环,遇到 continue 的时候,就会自动跳转到 i++。

5-3 死循环里使用循环控制语句

在完成了前面的内容的学习之后,我们来看看死循环怎么嵌套这种东西。

int i = 1, sum = 0;
for (; true; i++)
{
    if (i > 100) break;

    sum += i;
}

思考一下,这样写能不能完成求 1 到 100 的和的任务。
这里条件写的是 true。实际上 for 循环里的条件是可以不写的,默认是 true:for (; ; i++) 就等价于 for (; true; i++),但 while 和 do-while 不行。

跳转结构

Part 1 我们来试试写一个质数计算程序

C 语言里我们基本上是必学的算法:计算质数。那么算法我们就不啰嗦了,我们来看一下代码:

int val = int.Parse(Console.ReadLine());

bool isPrime = true;
for (int i = 2; i <= Math.Sqrt(val); i++)
{
    if (val % i == 0)
    {
        isPrime = false;
        break;
    }
}

if (isPrime)
    Console.WriteLine("{0} is a prime.", val);
else
    Console.WriteLine("{0} isn't a prime.", val);

Math.Sqrt 方法是求一个数字的平方根的。如果需要使用这个方法,和 Console.WriteLine 一样,我们依旧需要使用 using System; 这条引用指令。
我们推广一下,我们如果要找第一个大于 50 的合数的话,我们可以这么写:

int val = 51;
for (; ; val++)
{
    bool isPrime = true;
    for (int i = 2; i <= Math.Sqrt(val); i++)
    {
        if (val % i == 0)
        {
            isPrime = false;
            break;
        }
    }

    if (!isPrime)
    {
        break;
    }
}
Console.WriteLine("The first composite number greater than 50 is {0}.", val);

可以从代码里看出,我们 for 里嵌套了一个 for。初学的话,你可以把内层循环当成“个位数”,而外层循环当成“十位数”。在数值不断增大的过程中,个位数要变化 10 次(0 到 9)之后,十位数才会进一位。双层循环是一样的道理:内层循环在外层循环变化一次的时候,反复执行。直到内层循环完整执行完毕后,外层循环才会继续更新一次。
当内层循环完成后,下方的 if 条件必然会被遇到。此时就看 isPrime 变量此时是不是 true。如果是,则会执行 break,跳出循环。

稍微注意一下的地方是,内层循环里也有一个 break 语句。这个 break 语句只跟内层循环有关系:跳出循环也只到第 14 行,而不是直接两层循环都跳出。

显然。这种写法有一个无关痛痒的问题:这个 if (!isPrime) 单独写出来,逻辑看起来有点臃肿。因此,goto 语句诞生了。

Part 2 goto 语句和标签

我们先来说一下标签(Label)的概念。标签就是用来控制自定义跳转的机制。我们把标签放在一个位置,相当于传送门的终点;而 goto 语句相当于传送门的起点。代码在执行的时候一旦遇到这句话,就自动跳转到终点。

using System;

internal class Program
{
    private static void Main()
    {
        int val = 51;
        for (; ; val++)
        {
            bool isPrime = true;
            for (int i = 2; i <= Math.Sqrt(val); i++)
            {
                if (val % i == 0)
                {
                    isPrime = false;
                    goto OutputResult; // Here.
                }
            }
        }

    OutputResult:
        Console.WriteLine("The first composite number greater than 50 is {0}.", val);
    }
}

你可以试着运行一下程序。这个例子里的 OutputResult 就是一个标签。标签使用 内容: 的格式表示。在内部找到合数的时候,就自动跳转到输出结果的地方,这样就非常方便。

Part 3 用标签模拟循环

标签的灵活程度可以完整包含 break、continue 这些语句的执行。循环也是不在话下。如果我们禁用循环,我们可以使用标签来模拟循环。

internal class Program
{
    private static void Main()
    {
        int i = 0; sum = 0;

    Loop:
        if (++i > 100)
        {
            goto Next;
        }

        sum += i;
        goto Loop;

    Next:
        Console.WriteLine("The result is {0}.", sum);
    }
}

比如这么写代码。

Part 4 goto-case 语句

在 switch 语句里,我们使用 case 来控制数值的可能性。在 C# 里,我们有一种跳转模式,使用 goto 语句来跳转执行到指定的标签内容上的内容。
考虑一个例子。我们现在输入一个 1 到 12 的一个月份数值,然后求的是从 1 月份到现在这个输入的月份,期间一共多少天。为了简化问题计算,我们只考虑整月:比如我输入 3,那么程序就计算 1、2、3 月份一共多少天。
这个例子里,我们为了解决统计数据,我们可以倒着“加”。

int month = int.Parse(Console.ReadLine());
int day = 0;

switch (month)
{
    case 12: day += 31; goto case 11;
    case 11: day += 30; goto case 10;
    case 10: day += 31; goto case 9;
    case 9: day += 30; goto case 8;
    case 8: day += 31; goto case 7;
    case 7: day += 31; goto case 6;
    case 6: day += 30; goto case 5;
    case 5: day += 31; goto case 4;
    case 4: day += 30; goto case 3;
    case 3: day += 31; goto case 2;
    case 2: day += 28; goto case 1;
    case 1: day += 31; break;
}

Console.WriteLine(day);

我们考虑这个写法,goto case 数值 表示在这个部分执行完成后,自动跳转到指定情况的条件上去执行。举个例子,我们输入的 month 是 3,那么 switch 语句会自动跳转到 case 3 开始执行。此时 day += 31 执行完成,然后执行 goto case 2 语句。此时,程序并不会自动跳出 switch(按道理默认是自动跳出去),但是 goto case 语句可以控制代码跳转到 case 2 处继续执行 day += 28 的内容。这种写法就串联起来后面的逻辑了。
这种写法非常神奇,请注意这种写法格式。

顺带一提,我们也可以用 goto default 来指定跳转到 default 情况上去。

异常结构

异常体系是 C# 里最重要也是非常必要的控制执行逻辑的一种体系。
和一般的教材不同,异常可能会用到非常多的超纲的东西,所以它们都只能放在以后来讲。但是,由于这里我们把异常结构作为一种控制流程,因此放在这里给大家做介绍。另一方面,为了避免超纲的内容,我们可能会穿插一些超纲内容到这里,简单做一个介绍;但具体详情的话,我们就不得不放在以后介绍。

Part 1 异常的概念

异常(Exception),是将所有不期望程序遇到,但确实遇到了,但又为了避免程序产生严重崩溃问题的一种东西。在一些别的编程语言里,一旦程序出错,就会直接闪退。在 C# 里,我们拥有控制这种防止闪退的机制,来避免很多问题。
先来看一个简单的例子。

int a = 3, b = 0;
Console.WriteLine(a / b);

显然,数学知识就告诉了我们,我们无法使用 a / b,因为 b 此时是为 0 的(而 0 不能作为除数)。如果我们将其写入程序里执行:

using System;

internal class Program
{
    private static void Main()
    {
        int a = 3, b = 0;
        Console.WriteLine(a / b);
    }
}

你就会在运行的时候产生错误。在控制台程序里,我们会看到类似这样的信息:
image.png
而在 VS 里,我们会直接看到这个东西:
image.png
这个所谓的“Unhandled exception”直译过来就是“未处理的异常”。换句话说,异常是交给我们灵活处理的,为了避免程序崩溃、闪退。而这里的显示信息,是程序崩溃的时候,提示给开发人员看的。这种东西只要开发人员一看,它就知道了问题究竟出在具体的哪个位置上。比如下面的文字“at …”,就告诉你了错误是出在这个地方;而最后的“line 10”就是告诉你,错误的代码是在文件的第 10 行上。
这里显示的 System.DivideByZeroException 是整个异常错误而产生的一个数据封装。这个东西就被称为异常。显示的 DivideByZeroException 是这个异常的类型。所有的错误都会使用异常来表达,而异常的类型专门表示出现的错误,到底具体是什么。从这个异常的类型上直接看,就可以看出 divide by zero 是除以 0 的意思,而 exception 是这里“异常”这个单词的英文。以此可见,异常的类型全部以“Exception”单词结尾,而前面的单词拼凑起来,直译出来就可以大体知道异常到底是指示错误的问题是什么。

“Exception”这个单词原始的意思是“例外”。在程序设计里,如果翻译成例外,有时候不是很好理解;当然,你可以理解成“不属于正常程序执行行为的例外情况”。

这样一来,所有程序崩溃的具体缘由就使用异常变得更加体系化了。接下来我们就来说一下,异常的捕获和处理。

Part 2 try-catch 语句

2-1 示例 1

思考一下问题。假如,我们要做一个除法计算器,除法的被除数和除数由用户输入(Console.ReadLine 读取)。

int a, b;

Console.WriteLine("Please input the first number:");
a = int.Parse(Console.ReadLine());
Console.WriteLine("Please input the second number:");
b = int.Parse(Console.ReadLine());

Console.WriteLine("The result of the expression '{0} / {1}' is {2}.", a, b, a / b);

可是,我们输入的内容是程序无法预期控制的。比如用户输入一个字母 a、输入一个减号、甚至是别的什么东西, C# 都是不可能在输入的时候就知道这里必须是要求“非 0 的整数数据”的。因此,这个程序就会在某处产生异常。
此时,我们试着输入 a 和 3,结果显然会出错,且报错信息出在第 4 行:
事实上,由于 a 自身就已经不是一个数字,因此还没等到 3 的输入,程序就报错了。记住这里的 FormatException 异常类型。这个类型在稍后我们会用到。
而另一方面,我们为了避免输入错误而产生程序异常的信息提示,我们可以这么改装一下代码:

int a, b;

Console.WriteLine("Please input the first number:");
while (true)
{
    try
    {
        a = int.Parse(Console.ReadLine());
        break;
    }
    catch (FormatException)
    {
        Console.WriteLine("Input is invalid. Please re-input the first number:");
    }
}

Console.WriteLine("Please input the second number:");
while (true)
{
    try
    {
        b = int.Parse(Console.ReadLine());
        break;
    }
    catch (FormatException)
    {
        Console.WriteLine("Input is invalid. Please re-input the second number:");
    }
}

Console.WriteLine("The result of the expression '{0} / {1}' is {2}.", a, b, a / b);

代码稍微臃肿了一点,由于 a 和 b 输入的过程是完全一样的,所以我们就只讲一个。请看到第 3 行到第 15 行代码。

Console.WriteLine("Please input the first number:");
while (true)
{
    try
    {
        a = int.Parse(Console.ReadLine());
        break;
    }
    catch (FormatException)
    {
        Console.WriteLine("Input is invalid. Please re-input the first number:");
    }
}

首先,我们用上了死循环。死循环的作用,我们可以尝试看下里面的语句来确定。里面嵌套了一个 try-catch 语句。这个语句的意思是,我们尝试去做 try 下面的大括号里的操作。如果这段代码一旦出现错误,必然就会产生异常。此时,我们需要在 catch 后追加异常的类型,来表示这里我们到底需要捕获(Catch)什么类型的异常。异常一旦被捕获,程序就拥有了“复活”的权利,因为程序的异常被捕获后,程序就不会闪退了。
接着,我们在 catch 的大括号里写上“这个异常类型的异常产生后,我们应该怎么做”。从代码里可以看出,第 11 行就是出错的时候应该做的事情:输出一行文字给用户。文字虽然写的是英文,但是实际上很好翻译:“现在这个输入是不行的。请重新输出这个数字。”。而从文字可以看出,一旦输入失败,程序是不会退出的,而是执行死循环,让你重新输入一个数据进去。
而我们在 try 下面的大括号里加上了 break 语句。这句话加在这里很突兀,但是这么去理解就好:假设我们第 6 行代码没有出现异常,就说明我们的输入是正常无误的。那么既然是没有问题的话,我们就不用在死循环里反复重新输入数据了,这就是死循环和 break 语句一起而发挥的作用。
此时,我们再次输入 a 字母,程序就会提示你输入的数据不对,然后要求你重新输入,直到数据是一个整数为止。这避免了我们前文提到的输入数据随便导致的程序闪退崩溃的问题。

另外,请一定注意,break 语句就只给 switch 和循环语句提供流程控制的服务,因此这里的 try-catch 语句里使用了 break 语句,但它的跳转依旧是跟外层的 while (true) 有关系,而跟 try-catch 本身没有关系。 而且,try 和 catch 的大括号是不可省略的。它和 if、for 这类语句不同:if 等语句,当大括号里只包含一个独立的语句的时候,是可以不写大括号的;但是 try 和 catch 不可以省略大括号。

不过,程序还有一处问题。如果 b 是 0 怎么办?我们可以这么改写输出语句:

if (b != 0)
{
    Console.WriteLine("The result of the expression '{0} / {1}' is {2}.", a, b, a / b);
}
else
{
    Console.WriteLine("The divisor is 0, which isn't allowed in division operation.");
}

至此,程序就不会出现前面可能的两处错误了。当然了,除以 0 会产生 DivideByZeroException,你甚至可以使用 try-catch 语句来捕获这个异常类型,然后提示错误信息的文字来避免程序崩溃:

try
{
    Console.WriteLine("The result of the expression '{0} / {1}' is {2}.", a, b, a / b);
}
catch (DivideByZeroException)
{
    Console.WriteLine("The divisor is 0, which isn't allowed in division operation.");
}

“The divisor is 0, which isn’t allowed in division operation.”这段文字的意思是:“除数是 0,而这个数是不能用在除法操作里的。”。

2-2 示例 2

还记得之前提到的溢出吗?数据在溢出的时候,我们使用 checked 来控制溢出的时候产生错误,以提示溢出错误。在那篇文章里,展示到了一个叫做 OverflowException 的异常。这个异常就是专门指代溢出的。
我们可以改造代码。比如写一个加法计算器,当数据运算超出表示范围的时候,提示用户,输入数据计算结果无效。

int a, b;

// Inputting...

try
{
    Console.WriteLine(checked(a + b));
}
catch (OverflowException)
{
    Console.WriteLine(
        "The calculation is invalid because the result of {0} + {1} " +
        "is out of range of the integer value.", a, b
    );
}

因为字符串一行写不下,我就用了加号拼接字符串来将字符串折行。
当然,字符串折行还可以使用原义字符串:

Console.WriteLine(
    @"The calculation is invalid
because the result of {0} + {1}
is out of range of the integer value.", a, b
);

注意,折行后,字符串必须顶格书写,因为字符串里的所有字符(包括空格这些字符)也是字符串的一部分,系统是不处理的。

从这个例子里,我们可以看出,只要算术出现溢出问题,我们就产生异常来告知用户数据输入无效。

Part 3 异常捕获需要注意的地方

显然,异常可以避免程序崩溃和闪退,但是我们随时随地去查看问题和异常的源头是什么类型,然后都去捕获,这样真的好吗?怕是不见得。C# 里很多异常类型都是可以通过 catch 来捕获掉的,这样确实防止了程序崩溃,但很多时候,程序的闪退可以帮助我们程序员更好、更快地找到问题所在。试想一下,异常如果一旦被捕获,程序就不会产生闪退。全都捕获掉的话,就算我们遇到了问题,程序也不会闪退,这就会造成一个潜在的、我们无法发现或很难发现到的问题:毕竟这样的问题都被捕获掉了。因此,我们不建议随时随地都使用异常捕获。

“C# 里很多异常类型都是可以通过 catch 来捕获掉的”是想告诉你,C# 里不是所有异常都能捕获,但这部分的异常类型很少被用到;很有可能 C# 教程把语法全部介绍完毕了之后,这部分无法捕获的异常类型也不会介绍到。因此,你不必担心遇到它们;但另外一方面,你需要知道的是,确实存在这种异常类型。

从另外一方面来看,异常的捕获是需要一点点性能需求的,这会耽误一点点时间。虽然对你来说,时间并不够多,但是对于程序来说,影响是比较大的。可能你用别的处理过程和逻辑,执行效率会比异常捕获好一些,且可以达到完全一样的运行效果。比如前面我们捕获除以 0 的异常的问题,我们完完全全可以通过判断 b == 0 来过滤掉除以 0 的情况。异常控制流程是从错误本身出发考虑的,而 b == 0 直接是通过数据本身触发考虑的。虽然完成的方法不同,但目的是一样的:提示用户,0 不能作除数。但是,后者(b == 0 作为判断条件)的处理方式就比异常捕获要好:它直接避免了使用异常机制。

Part 4 异常实体的使用

在前文里,我们仅仅是捕获了异常的类型,然后提供对应的策略。但是有些时候,我们可能会需要使用异常的一些具体信息,来帮助程序员修复问题。这个时候,我们需要使用异常的实体。
我们拿这个例子来说。一旦抛出了异常后,我们在异常类型后紧跟一个变量:如果从理解的角度来说,你可以把这个变量当成是这个异常类型的一个实体。当异常抛出后,这个实体存储的东西就是整个异常在产生的时候,记录下来的具体错误信息(包括错误的具体文字信息、错误的相关类型、错误发生在哪里)。

try
{
    Console.WriteLine("The result of the expression '{0} / {1}' is {2}.", a, b, a / b);
}
catch (DivideByZeroException ex)
{
    Console.WriteLine("Try to divide by 0. The inner info: {0}", ex.Message);
}

比如这个例子,我们用了一个叫 ex 的变量。在 catch 里,我们使用 Console.WriteLine 输出一行文字到屏幕上,文字里使用到了 ex.Message 这个写法。紧跟的 .Message 表示获取这个实体里的文本错误信息的具体内容。

当然,这个 ex 异常的实体还包含了很多其它的东西,它们全部都是在异常出现的时候,记录下来的、对程序员有帮助的错误信息。不过这里就不啰嗦了,因为我们用不上它们;而且有些内容是超纲的。

Part 5 throw 语句

5-1 throw-new 语句

当然,除了我们处理系统产生的异常外,我们还可以自己产生一个异常。这个行为叫异常的抛出(Throw)。抛出一个异常需要了解一个语句:throw-new 语句。
假设我们有 5 个变量 a、b、c、d 和 e,通过输入一个 1 到 5 之间的数字来获取对应变量的数值,我们的代码可以这么写:

int index = int.Parse(Console.ReadLine());

switch (index)
{
    case 1: Console.WriteLine(a); break;
    case 2: Console.WriteLine(b); break;
    case 3: Console.WriteLine(c); break;
    case 4: Console.WriteLine(d); break;
    case 5: Console.WriteLine(e); break;
    default: throw new Exception("The index is invalid.");
}

注意 default 部分。当输入的数据不是 1 到 5 的话,就会执行 default 部分的内容。这里写的是 throw new Exception(“The index is invalid.”); 这样一个语句。
throw 开头的语句就是我们这里说的抛出异常的语句。当程序员为了调试程序需要,可以尝试添加这个语句来强制在执行到这里的时候自动产生类似前面一些图片里这样的严重错误信息,以帮助程序员了解程序的执行流程,找到和解决 bug。
注意写法。throw 后紧跟 new 单词。这个 new 是一个关键字,所以不能写成其它的东西。在 new 后跟上你要抛出的异常的类型名称。比如之前的 FormatException 啊,DivideByZeroException 等等。异常类型名称是需要你记住一些的;但是我们可以慢慢来,不用一口气记住很多,因此这里就这两个就可以了,再算上这里的 Exception,一共是三个。Exception 异常是一种“不属于任何异常类型的异常”。这种异常类型是当系统抛出的异常类型不够用的时候(换句话说,就是系统提供的那些异常类型都不属于的时候,这个 Exception 就可以用)。比如这里,我们就可以使用这个异常,然后跟一个小括号,里面写上异常的错误信息(用一般是字符串字面量)就可以了,比如代码里的“The index is invalid.”(编号无效)。

5-2 throw 实体 语句

在前文,我们捕获了异常,并使用了异常信息的实体的内容。当我们有时候不得不再次在 catch 里抛出这个异常的时候,我们可以使用 throw 实体 语句。

try
{
    // ...
}
catch (DivideByZeroException ex)
{
    throw ex;
}

这种格式下,我们就会把捕获的异常实体再次抛出来,这种行为称为异常的重抛出(Re-throw)。

5-3 throw 语句

在 catch 部分里,我们还可以用上一种特殊的异常抛出语句:throw;。

try
{
    // ...
}
catch (FormatException)
{
    throw;
}

在 catch 里写了一句 throw;,这就可以表示原封不动地把错误信息重新抛出来。你甚至可以不写出 ex 变量,就可以抛出。

在初学的时候,throw 实体; 和 throw; 确实没有明显的区别。但实际上,它们的调用堆栈(Calling Stack)是不同的。调用堆栈这一点对于 C# 非常重要,但因为内容极为复杂,我们将这个超纲内容放在以后讲。你可以把调用堆栈理解成做一件事经过多少人的手。throw; 语句会重新抛出原封不动的异常信息,它可以保证抛出的异常,记录的东西“高保真”:甚至是连哪些人动过这个物件都记得很清楚;但是 throw 实体; 语句的其它东西都一样,就只有调用堆栈不同:它会重置堆栈,使得调用方无法确认(比如说,如果我要查看谁动过我的奶酪,通过 throw 实体; 就无法确认了)。

总之,我们总是建议你使用 throw; 而不是 throw 实体;。

Part 6 总结

前文我们学到了使用 try-catch 语句来执行程序、捕获异常,以及重抛出异常。不过因为内容讲得复杂,学得简单,所以可能你看一遍也不太明白到底是什么。其实,没关系的:异常的话,按道理说是得将了调用堆栈、讲了面向对象的继承等等超纲知识点,才可以说的东西。但是这么讲解有一个弊端,就是没有保持内容的系统化。不管怎么说,教材可能跟我的顺序不同,这一点仅作参考。
而且,在 C# 里,异常控制还有一个叫做 finally 的控制部分(除了 try、catch 这两个控制部分外),但是因为这个内容是超纲的(这涉及到对象的内存释放,完全是理论知识),所以我们不能在这里介绍:它会用到非常后面的知识点。到时候我们再说。