在switch-case中定义变量时当心被“穿越” .
这篇文章的分类是C++,所以特此声明这里提到的规则只适用于C++。对于C语言,是有不同的一套规则的。
先来看看下面这段代码,有问题吗?
01.void RunStateMachine() 02.{ 03. switch(m_status) 04. { 05. case TASK_START: 06. int data = FormDataToSend(); 07. m_mailbox->Send(data); 08. m_status = TASK_SENT; 09. break; 10. case TASK_SENT: 11. //.. 12. break; 13. //.. 14. } 15.}
这就是今天写的一部分代码的原型,初看一下并没觉得有什么问题,但是编译器报错:initialization of 'data' is skipped by 'case' label。
switch-case的“穿越”
网上搜了一下,并且看了下标准,终于明白了来龙去脉。在C++中,switch-case中的case实质上只是一个标签(label),就像goto的标签一样。case中的代码并没有构成一个局部作用域,虽然它的缩进给人一种错觉,好像它是一个作用域。也就是说,所有在case里面定义的变量作用域都是switch{...},在后面其他case中依然可以访问到这个变量。而switch本质上相当于goto,因此下面紧跟switch的打印语句永远不会执行到。
01.switch(selector) 02.{ 03.cout << selector; 04.case selector_a: 05. int i = 1; 06.case selector_b: 07. // i在此仍然可见 08.}
那这和错误有什么关系呢?C++标准规定:
It is possible to transfer into a block, but not in a way that bypasses declarations with initialization. A program that jumps from a point where a local variable with automatic storage duration is not in scope to a point where it is in scope is ill-formed unless the variable has POD type (3.9) and is declared without an initializer. (The transfer from the condition of a switch statement to a case label is considered a jump in this respect.)
意思是说,如果一个程序的执行路径从代码中的点A(某个局部变量x还未定义)跳到代码中另一点B(该局部变量x已定义,并且定义的时候有初始化),那么编译器会报错。这样的跳跃可以是由于执行goto语句,或者是switch-case造成的。
这里有两种情况:首先,对于POD对象而言,只有当有初始化式的声明被跳过时才会报错。
01.switch(selector) 02.{ 03.case selector_a: 04. int i = 1; // 如果是"int i;",则编译器不报错,只有下面的警告 05.case selector_b: 06. cout << i; // warning: 使用未初始化i 07.}
值得注意的是,变量i是否有定义,空间大小这些在编译期就已确定,而初始化是运行时的行为。跳过的是初始化,而非它的定义。
其次,对于有显式定义构造函数的类对象来说,其声明被跳过时会报错。
class Employee { public: Employee() {..} void f() {..} }; .. switch(selector) { case selector_a: Employee e; // 编译报错! case selector_b: e.f(); }
当一个POD对象有初始化式或者一个类具有显式构造函数时,表明程序员希望初始化这个对象。如果程序的执行有任何可能会导致该初始化被跳过,程序很可能进入程序员意料之外的某个不正确状态。所以,标准明确禁止了这种行为。
当然了,对于没有初始化过的POD对象,后面的读取可能有问题,也可能没问题,取决于使用前是否进行过赋值。然后这一切都是运行时才知道,编译器无能为力。所以,大多数情况下并不报错。
好了,回到问题上来。出现上述编译错误的根本原因是对象的作用域“拖的”比较长,跨越了多个case。解决的办法是用大括号{}将每个case中的代码封成一个局部作用域。由于i具有局部作用域,因此也就不存在声明被跳过的问题了。
01.switch(selector) 02.{ 03.case selector_a: 04. { 05. int i = 1; 06. } 07.case selector_b: 08. // i在此不可见 09.}
goto的“穿越”
在翻看标准的时候,发现了另外一个有趣的例子。这个例子是为了说明向前穿越过变量的定义会有问题,这个在上面已经讨论过了。但是注释说,在执行goto ly的时候会调用对象a的析构函数。
01.void f() 02.{ 03. // ... 04. goto lx; // 编译出错,原因见上分析 05. // ... 06.ly: 07. A a = 1; 08. // ... 09.lx: 10. goto ly; // OK, 跳跃到ly意味着a的析构函数被调用 11. A b = a; // OK, 这里属于a的作用域,但是永远不会执行到 12.}
这段程序形成了一个死循环,最先a的析构被调用,然后被构造,被析构,不断轮回。那么,为什么在执行goto ly的时候会调用a的析构函数呢?这个在标准里面也有专门说明:
Transfer out of a loop, out of a block, or back past an initialized variable with automatic storage duration involves the destruction of variables with automatic storage duration that are in scope at the point transferred from but not at the point transferred to.
意思说:当程序执行路径离开一个循环、局部作用域{..}、或者回到一个已初始化对象之前时,相应的析构函数会被调用。
这是可以理解的,上例中对象a的作用域是从A a=1开始,到f的右括号}结束。如果程序执行跳到A a=1之前,显然已经不在a的作用域了。离开作用域自然应该调用a的析构函数,就像中途抛出一个异常或者return返回一样。
小结
1. switch-case中的switch相当于goto, case相当于一个goto标签。
2. 在case中定义变量时必须在周围加{..}以形成局部作用域,否则编译报错。习惯上总是将case中的代码用{..}括起来,比较省事。是有些公司的coding convention之一。
01.switch(selector) 02.{ 03.case selector_a: 04. { 05. int i; 06. } 07.case selector_b: 08. // i在此不可见 09.}
3. 当goto往回穿越过一个对象的初始化时,该对象的析构函数被调用。