[有人要求对Duff Device做一些更详细的解释,该Device乍一看似乎连正确解析都无法完成。这就是我的回复。]

这东西是个难题,不是吗?

这里有几个不同的问题需要理解。

一个原因是,就编译器而言,switch语句的正式语法不是

	switch ( expression )
		{
		case constant-expression :
			statement-list
			break ;

		case constant-expression :
			statement-list
			break ;

		...

		default :
			statement-list
			break ;
		}

实际上,语法只是

	switch ( expression )
	       statement

其中语句可以是单个语句,也可以是花括号括起来的语句列表。正如你所能想象的,这与用于if, 对于,而while语句的语法规范是相同的。

此外,语句的语法规则实际上规定,任何语句(无论在哪里)都可以被“标记”,可以是goto标签(即可以成为goto目标的一个标签goto)或case标签。

到目前为止,这意味着switch语句中的case标签不一定必须紧挨着包围switch语句体的最外层花括号;它们可以藏在内部的嵌套块中。

下一个问题是,如果case标签不一定紧挨着switch语句的最外层花括号,那么编译器如何找到它们并将它们匹配起来?对此有多种回答方式,但也许最简单、最令人信服的是说,编译器实现case标签的方式类似于实现goto标签,而switch语句实际上是一种“计算goto”。

这有点过于简化了,因为一个goto可以跳转到函数内的任何位置,而case标签必须始终位于某个enclosingswitch语句的内部(如果不是紧挨着的话)。但关键在于,编译器根本不会注意到case标签可能“隐藏”在switch语句体内的嵌套块中。

如果你真的想理解这一点,值得再讨论几个点。你可能没有意识到,在包括C在内的许多语言中,使用goto跳转到循环体是合法的(尽管不推荐)。例如,这段代码

	int i = 7;
	goto inside;
	for(i = 0; i < 10; i++)
		{
	inside:	printf("%d ", i);
		}

打印“7 8 9 ”。相关的代码

	i = 42;
	goto in2;
	for(i = 0; i < 10; i++)
		{
	in2:	printf("%d ", i);
		}

打印“42 ”,相关的代码

	i = 42;
	goto in3;
	for(i = 0; i != 10; i++)
		{
	in3:	printf("%d ", i);
		}

几乎会永远运行,打印数千或数十亿个数字。

我必须强调,你可能永远不会想这样跳转到循环中,原因非常少,甚至没有。它之所以可能,在某种程度上是一种意外,任何关心编程风格的人都会认为这种有疑问的自由是你永远不想使用的。(他们也是对的:这种有疑问的自由是你永远不想使用的。)然而,思考一下goto如何跳转到循环中,也许更容易想象当switch语句导致跳转到一个嵌套在内部块中的case标签时发生了什么。

另一个值得一提的点是为什么编译器一开始就被写成这样。为什么switch语句的官方语法只是

	switch ( expression )
	       statement

而不是我开始讨论时所说的更明显、更明确的语法?如果明确的语法是官方语法,那么就不可能编写像Duff Device这样有疑问的、无法理解的代码。

答案——这可能听起来有点蹩脚,但不管怎样——与使编译器更容易、更简洁地编写有关。有很多与花括号括起来的语句列表相关的机制,在任何控制流语句可能使用花括号括起来的列表作为其主体的上下文中使用这些机制都很好。事实上,重用这些机制是如此之好,以至于它值得这样做,即使这意味着这些机制需要通过一些额外的复杂性来处理夹杂的case标签,而这种复杂性实际上只在解析的块是switch语句的主体时才需要。

事实上,每当人们说一个令人惊讶的特性是意外的、并非有意为之,它只是“从”某个系统的选择实现中“掉出来”时,他们谈论的就是这种情况。我认为,从来没有打算让case标签在嵌套的内部块中被接受(更不用说正确处理了)。但是,当决定将块语句处理机制重用于switch语句的主体时,这意味着该机制必须了解case标签,这就意味着case标签恰好在循环体内的语句前面被接受,只要外面有一个switch语句可以让它们连接起来。此外,虽然我不会争论这是否是意外的、故意的,还是故意仔细编码习惯的结果,但事实证明,case标签不仅被接受,而且“正常”工作,即使它们是深度嵌套的。

*     *     *

我不确定你更困惑的是交错循环和switch语句的语法和语义含义,还是Duff的代码利用这种组合来实现实际算法的特定方式。但如果你对后者有问题,现在让我们来看看Duff Device本身。实际上,为了清晰起见,我将介绍Duff Device的一个变体,即标准memcpy函数的重写。(我猜你知道memcpy做什么。)一个直接、基础的memcpy实现可能看起来像这样

	void *memcpy(void *dest, const void *src, size_t n)
	{
		char *destp = dest;
		const char *srcp = src;

		for(; n > 0; n--)
			*destp++ = *srcp++;

		return dest;
	}

这段代码运行得相当好。但它并不完美,有些地方我们可以担心,其中一个就是效率。

当前代码存在的问题是,它每次循环都会检查n是否已减至0。也就是说,如果我们复制1000个字节,代码的运行将涉及1000次字节复制操作,以及srcp的1000次增量操作,以及destp的另外1000次增量操作,以及n的1000次减量操作,以及n的1000次比较操作。看起来似乎没有办法绕过这一点,但事实是可以的。这种技术被称为“循环展开”,我们可以通过修改上面memcpy示例代码集来展示它。假设我们重写循环如下:

		for(; n > 0; n -= 2)
			{
			*destp++ = *srcp++;
			*destp++ = *srcp++;
			}

意图是实现完全相同的结果,但工作量少一些(尽管代码量多一些,这是一个常见的权衡)。使用修改后的代码,如果n开始时是1000,仍然会有1000次赋值和1000+1000次对srcpdestp的增量,但是,只有500次对n与0的比较,而不是1000次。此外,将有500次减法,而之前是1000次减量。

然而,你可能已经注意到,修改后的代码有一个bug:它只在n是偶数时才能正常工作。如果n是奇数,它会复制太多。(事实上,由于n的类型是size_t,它是无符号的,循环可能会失控,基本上会永远运行,而不是仅仅多复制一个字节。这种情况类似于我们第三个跳转到循环的例子,它将i设置为42,但测试的是i != 10。但这只是一个次要问题,所以我不会再赘述了。)

为了修复代码,我们需要检查n是否是奇数,并处理复制单个字节的情况,因为总是成对复制显然无法正确处理奇数。

		if(n % 2 == 1)
			{
			*destp++ = *srcp++;
			n--;
			}

		for(; n > 0; n -= 2)
			{
			*destp++ = *srcp++;
			*destp++ = *srcp++;
			}

如果我们想进一步展开循环。如果我们在循环中放置四个赋值语句,那么在主循环中我们将只执行n/4次减法和n/4次对n的比较。但现在我们不仅要担心n是否是奇数,还要担心它除以4的余数是1、2或3。所以代码可能看起来像

		switch(n % 4)
			{
			case 3: *destp++ = *srcp++; n--;
			case 2: *destp++ = *srcp++; n--;
			case 1: *destp++ = *srcp++; n--;
			}

    		for(; n > 0; n -= 4)
			{
			*destp++ = *srcp++;
			*destp++ = *srcp++;
			*destp++ = *srcp++;
			*destp++ = *srcp++;
			}

现在你大概开始明白这是怎么回事了。当我们通过4的因子展开循环,将n的递减和测试开销减少4倍时,代码大小也增加了4倍,这已经够糟糕了。但更糟糕的是,因为处理余数的初始情况意味着代码膨胀率更像8倍。我们不能少写几行*destp++ = *srcp++吗?

正如FAQ列表指出的那样,Duff Device的本质在于它“通过将switch语句与每N个字节进行复制的循环交织在一起,解决了处理剩余字节的问题。”其思想是跳转到展开循环的中间,这样第一次循环就不是完整的循环,而是处理(如果需要的话)非4的倍数的剩余字节。这顶多是一种很糟糕的做法——毕竟,我之前说过你永远不想利用C语言提供的跳转到循环中间的这种有疑问的自由。我们就要这么做,而我们使用switch而不是goto的事实并没有让这个练习少多少(如果有的话)有疑问。

总之,这里是交织技巧——也就是说,Duff Device的核心思想——应用于我们一直在处理的memcpy示例:

		int nn = n;	 /* need signed n to handle underflow */
				 /* also assumes initial n > 0 */

		switch(nn % 4)
			{
	    		for(; nn > 0; nn -= 4)
				{
			case 0:	*destp++ = *srcp++;
			case 3:	*destp++ = *srcp++;
			case 2:	*destp++ = *srcp++;
			case 1:	*destp++ = *srcp++;
				}
			}

这段代码是可行的,尽管有两个额外的复杂问题。一个是因为它在for循环中总是使用“-= 4”,在初始n不是4的倍数的情况下,计数会变成负数。所以我们必须在有符号变量上进行减法——如果我们继续从n(它是作为size_t传递的)中减去4,它永远不会变成负数,而是会回绕成一个非常大的正数,即一个大于0的数,这意味着循环会运行太久。(这是我之前试图避免跑题的解释。)

第二个复杂问题是,当前的代码再也无法处理初始n值为0的情况了。(“官方”版本的Duff Device也有同样的限制。)

我认为,我在这里提供的代码捕捉了Duff Device的核心思想。为了记录在案,我将解释这段代码与“官方”版本之间的区别。Duff将循环展开了8倍,预先计算了一个n,它是字节计数除以8(然后进行调整),这样它就可以使用“--n”而不是“n -= 8”,并且它使用一个do/while循环而不是一个对于循环。这三个区别主要是美学上的;一个主要的区别当然是“官方”版本的Duff Device根本不增加指针。这是因为指向的不是数据被复制到的常规内存位置,而是指向一个内存映射的设备寄存器;正在复制的字节实际上被传输到某种显示设备。(应用程序可能是某种动画,可能是实时的,这就是为什么效率如此重要。)