陈硕 giantchen_AT_gmail_DOT_com
最近研究整数到字符串的转换,读到了 Matthew Wilson 的《Efficient Integer to String Conversions》系列文章。(http://synesis.com.au/publications.html 搜 conversions)。他的巧妙之处在于,用一个对称的 digits 数组搞定了负数转换的边界条件(二进制补码的正负整数表示范围不对称)。代码大致如下,经过改写:
const char* convert(char buf[], int value) { static char digits[19] = { '9', '8', '7', '6', '5', '4', '3', '2', '1', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; static const char* zero = digits + 9; // zero 指向 '0' // works for -2147483648 .. 2147483647 int i = value; char* p = buf; do { int lsd = i % 10; // lsd 可能小于 0 i /= 10; // 是向下取整还是向零取整? *p++ = zero[lsd]; // 下标可能为负 } while (i != 0); if (value < 0) { *p++ = '-'; } *p = '/0'; std::reverse(buf, p); return p; // p - buf 即为整数长度 }
这段简短的代码对 32-bit int 的全部取值都是正确的(从 -2147483648 到 2147483647)。可以视为 itoa() 的参考实现,面试的标准答案。
读到这份代码,我心中顿时升起一个疑虑:《C Traps and Pitfalls》第7.7节讲到,C 语言中的整数除法(/)和取模(%)运算在操作数为负的时候,结果是 implementation-defined。(网上能下载到的一份简略版也有相同的内容,http://www.literateprogramming.com/ctraps.pdf 第7.5节。)
也就是说,如果 m、d 都是整数,
int q = m / d;
int r = m % d;
那么C语言只保证 m == q*d + r。如果 m、d 当中有负数,那么 q 和 r 的正负号是由实现决定的。比如 (-13)/4 == (-3)或 (-13)/4 == (-4) 都是合法的。如果采用后一种实现,那么这段转换代码就错了(因为将有 (-1) % 10 == 9)。只有商向 0 取整,代码才能正常工作。
为了弄清这个问题,我研究了一番。
我手头没有 ANSI C89 的文稿,只好求助于 K&R88,此书第 41 页第 2.5 节讲到 The direction of truncation for / and the sign of the result for % are machine-dependent for negative operands, ...。确实是实现相关的。为此,C89 专门提供了 div() 函数,这个函数算出的商是向 0 取整的,便于编写可移植的程序。我得再去查 C++ 标准。
第 5.6.4 节写到 If the second operand of / or % is zero the behavior is undefined; otherwise (a/b)*b + a%b is equal to a. If both operands are nonnegative then the remainder is nonnegative; if not, the sign of the remainder is implementation-defined. C++也没有规定余数的正负号(C++03 的叙述一模一样)。
不过这里有一个注脚,提到 According to work underway toward the revision of ISO C, the preferred algorithm for integer division follows the rules defined in the ISO Fortran standard, ISO/IEC 1539:1991, in which the quotient is always rounded toward zero. 即 C 语言的修订标准会采用和 Fortran 一样的取整算法。我又去查了 C99。
第 6.5.5.6 节说 When integers are divided, the result of the / operator is the algebraic quotient with any fractional part discarded. (脚注:This is often called "truncation toward zero".) C99 明确规定了商是向0取整,也就意味着余数的符号与被除数相同,前面的转换算法能正常工作。C99 Rationale (http://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf) 提到了这个规定的原因,In Fortran, however, the result will always truncate toward zero, and the overhead seems to be acceptable to the numeric programming community. Therefore, C99 now requires similar behavior, which should facilitate porting of code from Fortran to C. 既然 Fortran 在数值计算领域都做了如此规定,说明开销(如果有的话)是可以接受的。
最近的 n2800 草案第 5.6.4 节采用了与 C99 类似的表述:For integeral operands the / operator yields the algebraic quotient with any fractional part discarded; (This is often called truncation towards zero.) 可见 C++ 还是尽力保持与 C 的兼容性。
小结:C89 和 C++98 都留给实现去决定,而 C99 和 C++0x 都规定商向0取整,这算是语言的进步吧。
我主要关心 G++ 和 VC++ 这两个编译器。需要说明的是,用代码案例来探查编译器的行为是靠不住的,尽管前面的代码在两个编译器下都能正常工作。除非在文档里有明确表述,否则编译器可能会随时更改实现--毕竟我们关心的就是 implementation-defined 行为。
http://gcc.gnu.org/onlinedocs/gcc/Integers-implementation.html
GCC always follows the C99 requirement that the result of division is truncated towards zero.
G++ 一直遵循 C99 规范,商向0取整,算法能正常工作。
http://msdn.microsoft.com/en-us/library/eayc4fzk.aspx
The sign of the remainder is the same as the sign of the dividend.
这个说法与商向0取整是等价的,算法也能正常工作。
既然 C89/C++98/C99/C++0x 已经很有多样性了,索性弄清楚其他语言是怎么定义整数除法的。这里只列出我(陈硕)接触过的几种常用语言。
http://java.sun.com/docs/books/jls/third_edition/html/expressions.html#15.17.2
Java 语言规范明确说 Integer division rounds toward 0. 另外对于 int 整数除法溢出,特别规定不抛异常,且 -2147483648 / -1 = -2147483648 (以及相应的long版本)。
http://msdn.microsoft.com/en-us/vcsharp/aa336809.aspx
C# 3.0 语言规定 The division rounds the result towards zero. 对于溢出的情况,规定在 checked 上下文中抛 ArithmeticException 异常;在 unchecked 上下文里没有明确规定,可抛可不抛。(据了解,C# 1.0/2.0 可能有所不同。)
Python 在语言参考手册的显著位置标明,商是向负无穷取整。Plain or long integer division yields an integer of the same type; the result is that of mathematical division with the `floor' function applied to the result.
http://docs.python.org/reference/expressions.html#binary-arithmetic-operations
Ruby 的语言手册没有明说,不过库的手册说到也是向负无穷取整。The quotient is rounded toward -infinity.
http://www.ruby-doc.org/docs/ProgrammingRuby/html/ref_c_numeric.html#Numeric.divmod
Perl 语言默认按浮点数来计算除法,所以没有这个问题。Perl 的整数取模运算规则与Python/Ruby一致。
http://perldoc.perl.org/perlop.html#Multiplicative-Operators
不过要注意,use integer; 有可能会改变运算结果,例如。
print -10 % 3; // => 2 use integers; print -10 % 3; // => -1
Lua 缺省没有整数类型,除法一律按浮点数来算,因此不涉及商的取整问题。
可以看出,在整数除法的取整问题上,语言分为两个阵营,脚本语言彼此是相似的,C99/C++0x/Java/C# 则属于另一个阵营。既然 Python 和 Ruby 都是用 C 实现的,但是运算规则又自成一体,那么必定能从代码中找到证据。
Python 的代码很好读,我很快就找到了 2.6.4 版实现整数除法和取模运算的函数 i_divmod()
http://svn.python.org/view/python/tags/r264/Objects/intobject.c?revision=75707&view=markup
注意到这段代码甚至考虑了 -2147483648 / -1 在32-bit下会溢出这个特殊情况,让我大吃一惊。宏定义UNARY_NEG_WOULD_OVERFLOW 和函数 int_mul() 前面的注释也值得一读。
Ruby 的代码要混乱一些,花点时间还是能找到,这是 1.8.7-p248 的实现,注意 fixdivmod() 函数。
http://svn.ruby-lang.org/cgi-bin/viewvc.cgi/tags/v1_8_7_248/numeric.c?view=markup
注意到 Ruby 的 Fixnum 整数的表示范围比机器字长小1bit,直接避免了溢出的可能。
既然 C/C++ 以效率著称,那么应该是贴近硬件实现的。我考察了几种熟悉的硬件平台,它们基本都支持 C99/C++0x 的语意,也就是说新规定没有额外开销。列举如下。(其实我们只关系带符号除法,不过为了完整性,这里一并列出 unsigned/signed 整数除法指令。)
Intel x86 系列的 DIV/IDIV 指令明确提到是向0取整,与 C99/C++0x/Java/C# 一致。
很奇怪,我在 MIPS 的参考手册里没有查到 DIV/DIVU 指令的取整方向,不过根据 Patternson&Hennessy 的讲解,似乎向0取整硬件上实现起来比较容易。或许我没找对地方?
ARM 没有硬件除法指令,所以不存在这个问题。Cortex-M3 有硬件除法,SDIV/UDIV 指令都是向0取整。Cortex-M3 的除法指令不能同时算出余数,这很特殊。
MMIX 是 Knuth 设计的 64-bit CPU,替换原来的 MIX 机器。DIV 和 DIVU 都是向负无穷取整(依据 TAOCP 第1.2.4节的定义,在第一卷 40 页头几行),这是我知道的惟一支持 Python/Ruby 语义的"硬件"平台。
总结:想不到小小的整数除法都有这么大名堂。一段只涉及整数运算的代码,即便能在各种语法相似的语言里运行,结果也可能完全不同。