Dans l'article précédent " Questions sur l'échange de deux objets " (notez que le nom a été changé), nous avons discuté de plusieurs méthodes d'échange de deux variables et donné une formule formelle. Dans cet article, nous aborderons les questions d'efficacité et de faisabilité. (Remarque: l'idée sur ce sujet a été principalement déclenchée par les commentaires faits par les amis de farproc sur l'article précédent.)
Méthode des variables intermédiaires
Tout d'abord, regardons le code qui utilise la méthode d'échange la plus simple et la plus directe:
{
int tmp;
tmp = a;
a = b;
b = tmp;
}
Compte tenu des caractéristiques de la langue elle-même, ces codes effectuent les tâches suivantes:
- Allouez de l'espace sur la pile pour la variable entière tmp;
- Mettez la valeur de a dans tmp;
- Mettez la valeur de b dans a;
- Mettez la valeur de tmp dans b;
- Libérez l'espace de pile alloué pour tmp.
Mais en fait? Jetons un coup d'œil au code d'assemblage généré:
movl b,% eax; charger b de la mémoire vers le registre eax
movl a,% edx; charger a de la mémoire pour enregistrer edx
movl% eax, a; stocker le contenu de eax dans la mémoire a
xorl% eax,% eax; effacer eax
movl % edx, b; stocker le contenu d'edx en mémoire b
Il semble que les instructions de montage ne soient pas aussi compliquées que nous le pensions. Comme la variable doit être chargée de la mémoire vers le registre pour participer à l'opération, il suffit d'échanger les deux variables dans l'ordre inverse puis de la stocker dans la mémoire. Juste quatre instructions pour échanger des données entre la mémoire et les registres, il semble qu'il n'y ait pas d'opération d'échange. Et pourquoi eax devrait-il être effacé ici? Parce que le registre eax est spécialement utilisé pour stocker la valeur de retour de la fonction, et que notre fonction de test est très simple, sauf pour effectuer les opérations ci-dessus, le reste est de retour 0;, donc cela n'a rien à voir avec l'échange de variables. Comme vous pouvez le voir ci-dessus, le compilateur fait plus de travail pour nous que nous ne le pensions.
XOR
Ensuite, regardons le code basé sur l'échange XOR:
{
a ^ = b;
b ^ = a;
a ^ = b;
}
Ce code a l'air très pur, pas une seule phrase n'est inutile (ce qui signifie que toutes les opérations sont liées à l'échange, et qu'il n'y a pas d'opération d'allocation d'espace variable temporaire comme dans l'exemple ci-dessus), et le code correspond directement à l'opération: trois OU exclusifs . Intuitivement, nous pensons que ce devrait être le plus efficace. Mais son effet secondaire est que la lisibilité du code est considérablement réduite (notez que la lisibilité est très importante), et certaines personnes pensent que cela en vaut la peine en raison de l'efficacité qu'il apporte. Voyons si cela en vaut la peine.
Voici le code d'assemblage correspondant au code ci-dessus:
movl b, %eax ;将b从内存载入寄存器eax
movl a, %ecx ;将a从内存载入寄存器ecx
movl %eax, %edx ;将eax的值保存到edx中
xorl %ecx, %edx ;ecx与edx异或
xorl %edx, %eax ;edx与eax异或
xorl %eax, %edx ;eax与edx异或
movl %eax, b ;将eax的值存入到内存b中
xorl %eax, %eax ;将eax置0:设置返回值,与上例中一样
movl %edx, a ;将edx的值存入到内存a中
哦,好像有点晕了。
它总共用了四次内存与寄存器之间的数据移动操作,一次寄存器之间的赋值,以 及三次异或运算。
我很诧异编译器会产生这样的汇编代码,我怀疑是编译选项出了问题(这是在-O2下 的结果),于是试了-O3的结果,居然也是完全一样,更令人意想不到的 是,在-O1下产生的结果居然是最简洁的。不过我们先来看上面这些代码都做了些 什么操作,是否都是必要的操作。
“意外”现象分析
首先我们将上面的C代码改写一下(现在想来才觉得C代码其实也是一样的迷惑 人,我并不清楚它到底经过了哪些步骤,而只知道它能交换两个整型变量的值而 已):
{
int tmp;
tmp = a ^ b; //得到异或的中间结果,即任何a、b中与它
//异或,都会得到另外一个的值(对比参考
//第一篇文章中关于加和乘情况的讨论)
b = tmp ^ b; //b的最终结果:b=(a^b)^b=a^(b^b)=a
a = tmp ^ a; //a的最终结果:a=(a^b)^a=b^(a^a)=b
}
现在,我们来将汇编代码逐行翻译为C代码来看看(忽略内存与寄存器之间的数据 交换):
int tmp; //寄存器edx对应变量tmp
tmp = b;
tmp = a ^ tmp; //对应于tmp = a ^ b;
b = tmp ^ b;
tmp = b ^ tmp;
a = tmp; //对应于a = tmp ^ b;
与我们转换后的代码相比,对这段代码编译器好像有点犯迷糊了。我们明明没有 用中间变量的代码,它居然不仅用了中间变量,而且还多用了两个赋值操作。
接下来我们再看在-O1下产生的结果:
movl b, %eax ;将b载入到寄存器eax
movl %eax, %edx ;将eax的值保存到edx
xorl a, %edx ;内存a与edx异或,结果保存到edx,得到中间结果
xorl %edx, %eax ;edx与eax异或,结果到eax,得到b的最终值,即a
movl %eax, b ;保存到内存b
xorl %eax, %edx ;edx与eax异或,结果到edx,得到a的最终值,即b
movl %edx, a ;保存到内存a
movl $0, %eax ;设置返回值
这一结果与我们手工转换的代码是类似的。但它不仅进行了四次内存与寄存器之 间的数据移动操作(对应于中间变量交换的情况),而且还进行了一次寄存器之 间的赋值,两次寄存器之间的异或运算,以及一次寄存器与内存之间的异或运算 (应该包含一次内存与隐含寄存器之间的数据移动,以及一次异或运算)。由此 看来,-O1产生的代码确实不如-O2产生的代码效率高,编译器并没有犯迷糊。
结论
很明显可以看出,异或方式的效率比预期的要坏得多,而且要比采用中间变量的 方式更坏。现在看来,如果我们一开始就从汇编及CPU的执行流程上来考虑的话, 就可以很容易的得出这一结论。在机器的角度来考虑交换两个整型变量(即相对 应的内存)的值,只需要将两个变量的值载入到寄存器中,然后按相反的对应关 系使用,或是按相反的对应关系保存到内存中即可,完全不需要经过中间计算。 而用异或方式,除了上述内存与寄存器之间的数据移动操作外,还需要进行三次 的异或操作(以及可能由此带来的移动操作)。这个结论是显而易见的。
采用异或的方式,我们不仅牺牲了可读性,而且还牺牲了效率,所以并不可取。
其它的方式,如加、乘等,用脚趾头想想也知道结果了,所以就不再讨论了。
说明
以上的结果,只是根据由C代码生成的汇编代码的行数,及其内存与寄存器之间数 据移动的次数等方面比较它们的效率;C代码也是很简单而纯粹的整型变量交换, 与实际情况差别较大;而且最重要的是没有来实际测量它们的运行时间,因此得出 的结论并不一定正确。
本次只讨论的是对整型变量交换的情况,而实际中要交换的对象是多种多样的。 比如在C++中,最常见的应该就是类对象的交换,甚至是两个不知道何种类型的对 象的交换(考虑模板类的情形)。
并不是所有对象都支持异或、加、乘的运算,所以这些方法就基本舍弃了,但仍要 重视它们所带来的思想上的东西(这种情况下仍然有可以用它们,但是很危险, 参见注1)。而基于中间变量的方式也要加以小心,一些对 象必须提供合适的拷贝构造函数和赋值运算符函数,才能保证交换操作在语义上 也是正确的,比如那些内部含有指针成员的类对象。
更广泛的结论
总的来说,采用中间变量方式交换两个对象的值,是最通用、可读性最高、效 率比较高的一种方式。在此我建议大家在一般情况下,都采用这种方式。 (注2)注
[1] 我们可以将对象看成若干个字符类型变量的数组,从而可以使用异或等方式。 但是,这并不能保证它的语义是正确的,尤其是在C++中。可以这样说,在实际情 况中,这样的操作几乎总是会带来错误。
[2] 说到最后,还不如原来就不要知道这种方法呢:)
[n] 我的系统平台是Debian 4.1.1、GCC 4.1.2,所有编译选项默认均为-O2,编译为 汇编代码的选项为-S。
[n+1] farproc的汇编结果是另一种情况。在进行交换之前数据已经载入到寄存器中,从而考虑的只有寄存器中的运算。下面是他的留言:
经过我的测试(vc2005 release),使用一个临时变量的交换方式还是效率最高的。位异或的次之,相加或相乘的最慢。
其实看一下生成的汇编码就很清楚了。
使用临时变量版本:
mov eax,edi
mov edi,esi
mov esi,eax
位异或版本:
xor edi,esi
xor esi,edi
xor edi,esi
加减版本:
add edi,esi
mov ecx,edi
sub ecx,esi
mov esi,ecx
sub edi,esi
[n+2] 思想在交流中迸发:kebing.zh 在 gmail 点 com
转载自:http://hi.baidu.com/bellgrade/blog/item/07664e5801deed202934f02f.html