C/C++ 中的算術及其陷阱

不告訴你我是誰發表於2021-04-02

概述

無符號數和有符號數是通用的計算機概念,具體到程式語言上則各有各的不同,程式設計師是解決實際問題的,所以必須熟悉程式語言中的整數。C/C++ 有自己特殊的算術運算規則,如整型提升和尋常算術轉換,並且存在大量未定義行為,一不小心就會產生 bug,解決這些 bug 的最好方法就是熟悉整數性質以避免 bug。

我不是語言律師(非貶義),對 C/C++ 算術特性的瞭解主要來自教材和網際網路,但基本上都查閱 C/C++ 標準驗證過,C 和 C++ 在整數性質和算術運算上應該是完全相同的,如果有錯誤請指正。

C/C++ 整數的陰暗角落

C/C++ 期望自己可以在所有機器上執行,因此不能在語言層面上把整數的編碼、性質、運算規定死,這讓 C/C++ 中存在許多未規定的陰暗角落和未定義行為。許多東西依賴於編譯器、作業系統和處理器,這裡通稱為執行平臺。

  • 標準沒有規定整數的編碼,編碼方式依賴於執行平臺。

  • char是否有符號依賴於執行平臺,編譯器有選項可以控制,如 GCC 的 -fsign-char。

  • 移位大小必須小於整數寬度,否則是未定義行為。

  • 無符號數左移 K 位結果為原來的 2^K 次方,右移 K 位結果為原來的數除 2^K 次方。僅允許對值非負的有符號數左移右移,運算結果同上,對負數移位是未定義的

  • 標準僅規定了標準內建整數型別(如int等)的最小寬度和大小關係(如long不能小於int),但未規定具體大小,如果要用固定大小的整數,請使用擴充整數型別(如uint32_t)等。

  • 無符號數的溢位是合法的,有符號數溢位是未定義行為

整型字面量

常常有人說 C/C++ 中的整數字面量型別是int,但這種說法是錯誤的。C/C++ 整形字面量究竟是什麼型別取決於字面量的格式和大小。StackOverflow 上有人問為什麼在 C++ 中(-2147483648> 0)返回true,程式碼片段如下:

if (-2147483648 > 0) {
    std::cout << "true";
} else {
    std::cout << "false";
}

現在讓我們來探索為什麼負數會大於 0。一眼看過去,-2147483648似乎是一個字面量(32 位有符號數的最小值),是一個合法的int型變數。但根據 C99 標準,字面量完全由十進位制(1234)、八進位制(01234)、十六進位制(0x1234)識別符號組成,因此可以認為只有非負整數才是字面量,負數是字面量的逆元。在上面的例子中,2147483648是字面量,-2147483648是字面量2147483648的逆元。

字面量的型別取決於字面量的格式和大小,C++11(N3337 2.14.2)規則如下:

N3337 2.14.2

對於十進位制字面量,編譯器自動在intlonglong long中查詢可以容納該字面量的最小型別,如果內建整型無法表示該值,在擴充整型中查詢能表示該值的最小型別;對於八進位制、十六進位制字面量,有符號整型無法表示時會選擇無符號型別。如果沒有足夠大的內建/擴充整型,程式是錯誤的,GCC/Clang 會發出警告。

在 C89/C++98 沒有long long和擴充整型,因此在查詢完long後查詢unsigned long

現在再看上面的程式碼段就很清晰了,在 64 位機上,不論是 C89/C++98 還是 C99/C++11,都能找到容納該值的long型別(8 位元組),因此列印false。在 32 位機上,long佔據 4 個位元組無法容納字面量,在 C89/C++98 下,2147483648的型別為unsigned long,逆元-2147483648是一個正數(2^32 - 2147483648),列印true;在 C99/C++11 下,2147483648的型別為long long,逆元-2147483648是一個負數(-2147483648),列印false

經過以上分析,可以判斷出提問者是在 32 位機上使用 C++98 做的實驗。

和字面量有關的另一個有意思的問題是INT_MIN的表示方法。《深入理解計算機系統(第 3 版)》2.2.6 中介紹的程式碼如下:

/* Minimum and maximum values a ‘signed int’ can hold. */
#define INT_MAX 2147483647
#define INT_MIN (-INT_MAX - 1)

《深入理解計算機系統》沒有給出解釋,但這種寫法很顯然為了避免巨集INT_MIN被推導為long long(C99/C++11)或unsigned long(C89/C++98)。

整型提升與尋常算術轉換

再看一個 stackoverflow 上的提問Implicit type promotion rules,通過這個例子來了解 C/C++ 算術運算中的整型提升integer promotion)和尋常算術轉換usual arithmetic conversion)。提問者編寫了以下兩段程式碼,發現在第一段程式碼中,(1 - 2) > 0,而在第二段程式碼中(1 - 2) < 0。這種奇怪的現象就是整型提升和尋常算術轉換導致的。

unsigned int a = 1;
signed int b = -2;
if(a + b > 0)
    puts("-1 is larger than 0");
// ==============================================
unsigned short a = 1;
signed short b = -2;
if(a + b > 0)
    puts("-1 is larger than 0"); // will not print

整型提升和尋常算術轉換涉及到整型的秩(優先順序),規則如下:

  • 所有有符號整型的優先順序都不同,即使寬度相同。

    假如intshort寬度相同,但int的秩大於short

  • 有符號整型的秩大於寬度比它小的有符號整型的秩

    long long寬度為 64 位元,int寬度為32位元,long long的秩更大

  • long long的秩大於longlong的秩大於intint的秩大於signed char

  • 無符號整型的秩等於對應的有符號整型

    unsigned int的秩等於對應的int

  • char的秩等於unsiged charsigned char

  • 標準整型的秩大於等於對應寬度的擴充整型

  • _Bool的秩小於所有標準整型

  • 列舉型別的秩等於對應整型

上面的規則看似複雜,但其實就是說:內建整型是一等公民,擴充整型是二等公民,_Bool是弟弟,列舉等同於整型。

整型提升的定義如下:

C11 6.3.1.1

If an int can represent all values of the original type (as restricted by the width, for a bit-field), the value is converted to an int; otherwise, it is converted to an unsigned int. These are called the integer promotions.

在算術運算中,秩小於等於intunsigned int的整型(把它叫做小整型),如char_Bool等轉換為intunsigned int,如果int可以表示該型別的全部值,則轉換為unsigned int,否則轉換為unsigned int。由於在 x86 等平臺上,int 一定可以表示這些小整型的值,因此不論是有符號還是無符號,小整型都會隱式地轉換為 int,不存在例外(otherwise 所說的情況)。

在某些平臺上,int可能和short一樣寬。這種情況下,int無法表示unsigned short的全部值,所以unsigned short要提升為unsigned int。這也就是標準中說的“否則,它將轉換為unsigned int

// C++17
// 有符號數溢位是未定義行為,但在許多編譯器上能看到正常的結果,
// 這裡只是觀察現象,請不要認為有符號數溢位是合法的
#include <cfloat>
#include <climits>
#include <cstdio>
#include <type_traits>
int main()
{
  signed char cresult, a, b;
  int iresult;
  a = 100;
  b = 90;
  // a,b 提升為整型,a + b = 190 在 int 表示範圍內,沒有溢位。
  // int 型別的 a + b 賦給表示範圍更小的 char 型別 cresult(窄化),
  // 發生溢位,值為 190 - 256 = -66。
  cresult = a + b; /* C++17: cresult {a + b}; 編譯器報錯,不能將 int 窄化為 signed char */
  // a,b 提升為整型,a + b = 190 在 int 表示範圍內,沒有溢位。
  // int 型別的 a + b 賦給表示範圍相同的 int 型別 iresult,沒
  // 發生溢位,值為 190。
  iresult = a + b;
  printf("cresult: %d\n", cresult);
  printf("cresult: %d\n", iresult);

// ======== output ========
// cresult: -66
// cresult: 190

尋常算術型別 轉換規則如下:

6.3.1.8 Usual arithmetic conversions

Many operators that expect operands of arithmetic type cause conversions and yield result types in a similar way. The purpose is to determine a common real type for the operands and result. For the specified operands, each operand is converted, without change of type domain, to a type whose corresponding real type is the common real type. Unless explicitly stated otherwise, the common real type is also the corresponding real type of the result, whose type domain is the type domain of the operands if they are the same, and complex otherwise. This pattern is called the usual arithmetic conversions:

  • First, if the corresponding real type of either operand is long double, the other operand is converted, without change of type domain, to a type whose corresponding real type is long double.
  • Otherwise, if the corresponding real type of either operand is double, the other operand is converted, without change of type domain, to a type whose corresponding real type is double.
  • Otherwise, if the corresponding real type of either operand is float, the other operand is converted, without change of type domain, to a type whose corresponding real type is float.
  • Otherwise, the integer promotions are performed on both operands. Then the following rules are applied to the promoted operands:
    • If both operands have the same type, then no further conversion is needed.
    • Otherwise, if both operands have signed integer types or both have unsigned integer types, the operand with the type of lesser integer conversion rank is converted to the type of the operand with greater rank.
    • Otherwise, if the operand that has unsigned integer type has rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with unsigned integer type.
    • Otherwise, if the type of the operand with signed integer type can represent all of the values of the type of the operand with unsigned integer type, then the operand with unsigned integer type is converted to the type of the operand with signed integer type.
    • Otherwise, both operands are converted to the unsigned integer type corresponding to the type of the operand with signed integer type.

在算術運算中,不僅整數要轉換型別,浮點數也要轉換型別。浮點數沒有有符號/無符號之分,直接轉換為能夠容納運算元的最小浮點型別即可,如單精度浮點數和雙精度浮點數運算,單精度浮點數轉換為雙精度浮點數。

整數之間由於存在無符號/有符號的差異,轉換稍微複雜一點:

  1. 進行整型提升
  2. 如果型別相同,不轉換
  3. 如果符號相同,將秩低的型別轉換為秩高的型別
  4. 如果無符號型別的秩高於或等於其他運算元,將其他運算元轉換為該無符號數的型別
  5. 如果有符號數的型別可以表示其他運算元型別的全部值,將其他運算元轉換為該有符號數的型別
  6. 如果以上步驟都失敗,一律轉換為無符號數,再進行上述步驟

算術型別轉換是為了找到合理的公共型別,所以當整數符號相同時將較小的整型轉換為較大的整型,將精度較小的浮點數轉換為精度較大的浮點數。但 C 語言很古怪,當整型符號不同時會嘗試將整型轉換為無符號型別(無符號型別的秩不低於有符號型別時),這會導致負數被錯誤的當成非常大的正數。C 語言的算術型別轉換很可能是一個失敗的設計,會導致非常多難以發現的 bug,比如無符號和有符號數比較:

#include <stdio.h>
int main()
{
    unsigned int a = 100;
    int b = -100;
    printf("100 > -100: %d\n", a > b); // b 被轉換為 unsiged int,-100 變成一個很大的正數
    return 0;
}
// ===== output =====
100 > -100: 0

整型提升講的是的小整型轉換為秩更高的unsiged intint,當參加算術運算時發生,是尋常算術轉換的第一步,因此可以認為是尋常算術型別轉換的特例。結合之前的介紹的整形字面量,我們應該已經理解了 C/C++ 算術運算的完整過程:

  1. 推匯出字面量的型別
  2. 進行尋常算術轉換
  3. 計算

第一步中,如果字面量是十進位制字面量,字面量會被推導為有符號數;如果是八進位制、十六進位制字面量,可能會被推導為無符號數。第二步中,可能會同時出現無符號數和有符號數,尋常算術轉換可能會將有符號數轉換為無符號數,一定要小心再小心。

不僅發生尋常算術型別轉換可能導致 bug,誤以為發生了尋常算術型別轉換也可能導致 bug,就連 Apple 這樣的巨頭都在自己的安全編碼規範中翻了車,詳見Buggy Security Guidance from Apple

// int n, m;
if (n > 0 && m > 0 && SIZE_MAX/n >= m) {
    size_t bytes = n * m;
    // allocate “bytes” space
}

Apple 的原意是先判斷乘法是否溢位,再將乘積賦給一個足夠寬的變數避免溢位,但這個實現有兩個錯誤:

  • int型變數的最大值是INT_MAX而不是SIZE_MAX
  • nm都是int型變數,乘積溢位後會被擷取到int的表示範圍內,然後再賦給bytes

所以,在涉及型別寬度不同的算術型別時要格外小心,可能會出現結果被擷取後再賦給變數的情況。

有了這些知識,回頭看這一節中的 stackoverflow 問題。第一個程式碼塊中,兩變數的型別是intunsigned int,發生尋常算術型別轉換,int轉換為unsigned int,負數變正數 UINT_MAX - 1,相加後得到UINT_MAX,因此(1 - 2) > 0;第二個程式碼塊中,兩變數的型別是charunsigned char,發生整型提升,轉換為int,相加的到負數,因此(1 - 2) > 0

算術溢位檢測

\(\omega\) 位整數的和需要 \(\omega + 1\) 位才能表示,\(w\) 位整數的積需要 \(2\omega\) 位才能表示,計算後 C/C++ 僅擷取低 \(\omega\) 位,可能會發生溢位。C/C++ 不會自動檢測溢位,一旦發生溢位,程式就會在錯誤的狀態中執行。

由於編譯器會進行死程式碼消除dead code elimination)和未定義行為消除undefined behavior elimination),依賴 UB 的程式碼很可能會被編譯器消除掉,即使沒被消除掉,發生未定義行為就無法保證程式處於正確狀態,參考It’s Time to Get Serious About Exploiting Undefined Behavior。以一種錯誤的緩衝區溢位檢測方法來說明編譯器優化對程式碼的影響。

// 這個例子來自 https://www.kb.cert.org/vuls/id/162289
char buf[1024];
int len;
len = 1<<30;
// do something
if(buf+len < buf) // check
    // do something

如果len是一個負數,那麼buf + len < buf一定為真。這個邏輯是對的,但 C 語言中陣列越界是未定義行為,編譯器可以忽略依賴未定義行為的程式碼,直接消除掉if語句,因此上面的檢測實際上沒有任何用處。因此必須在有符號數溢位之前進行檢測。

對於無符號加法 \(c = a + b\),溢位後 \(c < a\space and \space c < b\);對於有符號加法 \(c = a + b\),當且僅當 \(a,b\) 同號,但 \(c\)\(a, b\) 符號相反時溢位,即 \(a, b > 0 \rightarrow c < 0 \space 或 \space a, b < 0 \rightarrow c > 0\)。注意,加法是一個阿貝爾群,不論是否溢位,\(c - a\) 都等於 \(b\),所以不能以和減加數的辦法檢測溢位。

#include <limits.h>

int signed_int_add_overflow(signed int a, signed int b)
{
    // 檢測程式碼不能導致有符號數溢位
    return ((b > 0) && (a > (INT_MAX - b))) || ((b < 0) && (a < (INT_MIN - b)));
}

int unsigned_int_add_overflow(unsigned int a, unsigned int b)
{
    // 無符號數溢位合法,檢測程式碼可以依賴溢位的值
    unsigned int sum = a + b;
    return (sum < a) && (sum < b);
}

乘法發生溢位時,將 \(2\omega\) 的積擷取到 \(w\) 位,得到的積一定不等於正常的數學運算的積。

#include <limits.h>
#include <stdio.h>

int unsigned_int_multiply_overflow(unsigned int a, unsigned int b)
{
    if (a == 0 && b == 0) {
        return 0;
    }
    unsigned int product = a * b; // 無符號溢位是合法的
    return (a != 0) ? product / a == b : product / b == a;
}

int signed_int_multiply_overflow(signed int a, signed int b)
{
    // a 和 b 可能為負,也可能為正,需要考慮 4 種情況
    if (a > 0) {     // a is positive
        if (b > 0) { // a and b are positive
            if (a > (INT_MAX / b)) {
                return 1;
            }
        } else { // a positive, b nonpositive
            if (b < (INT_MIN / a)) {
                return 1;
            }
        }            // a positive, b nonpositive
    } else {         // a is nonpositive
        if (b > 0) { // a is nonpositive, b is positive
            if (a < (INT_MIN / b)) {
                return 1;
            }
        } else { // a and b are nonpositive
            if ((a != 0) && (b < (INT_MAX / a))) {
                return 1;
            }
        } // End if a and b are nonpositive
    }     // End if a is nonpositive
    return 0;
}

位運算技巧

位運算是 C/C++ 的一大利器,存在大量的技巧,我不是這方面的高手,這裡只是介紹幾個最近學習中碰到的讓我開啟眼界的技巧,感興趣的可以參考這份清單(我沒有看)Bit Twiddling Hacks

  • 給定一個非空整數陣列,除了某個元素只出現一次以外,其餘每個元素均出現兩次。找出那個只出現了一次的元素。
// 利用異或消除相同元素
int SingleNumber(std::vector<int>& nums)
{
  int ret = 0;
  for (ssize_t i = nums.size() - 1; i >= 0; --i) {
    ret ^= nums[i];
  }
  return ret;
}
  • 消去二進位制數最低位的 1

可以觀察到,整數減一會消去最低位的 1(0 反轉為 1),低位的 0 全部反轉為 1,因此val & (val - 1)可以消去最低位的 1 且不再後面生成新的 1。

unsigned int val = /* something */;
val &= (val - 1); /* 消去最低位的 1 */

利用這個性質,可以快速計算出二進位制數中 1 的個數:

int CountOfBinaryOne(unsigned int val) {
    int cnt = 0;
    while (val != 0) {
        val &= (val - 1);
        ++cnt
    }
    return cnt;
}

當整數是 2 的整數冪時,二進位制表示中僅有一個 1,所以這個方法還可以用來快速判斷2 的冪。

int IsPowerOf2(unsigned int val) {
    return (val & (val - 1)) == 0;
}
  • 找出不大於 N 的 2 的最大冪

從二進位制的角度看,求不大於 N 的最大冪就是將 N 位數最高的 1 以下的 1 全部清空。可以不斷消除低位的 1,直到整數為 0,整數變成 0 之前的值就是不大於 N 的 2 的最大冪。

這裡還有更好的方法,在 O(1) 時間, O(1) 空間實現功能。先將最高位的 1 以下的位元全部置為 1,然後加一(清空全部為 1 的位元,並將進位),右移一位。舉例如下:

01001101 --> 01111111 --> 01111111 + 1 --> 10000000 --> 01000000

程式碼如下:

unsigned int MinimalPowerOf2(unsigned int val) {
    n |= n >> 1;
    n |= n >> 2;
    n |= n >> 4;
    n |= n >> 8;
    n |= n >> 16;
    return (n + 1) >> 1;
}

這個實現無法處理最高位為 1 的情況,這時val會被或操作變成UINT_MAX,最後(n + 1) >> 1得到0。正確的版本如下:

unsigned int MinimalPowerOf2(unsigned int n)
{
    if ((int)n < 0) { // 最高位為 1
        return 1 << 31;
    }
    n |= n >> 1;
    n |= n >> 2;
    n |= n >> 4;
    n |= n >> 8;
    n |= n >> 16;
    return (n + 1) >> 1;
}

總結

  • 儘可能不要混用無符號數和有符號數,如果一定要混用,請小心謹慎

  • 在涉及不同大小的資料型別時要小心,可能存在溢位和截斷。

  • 只要存在有符號數就要考慮溢位導致的未定義行為和可能的符號反轉

  • 儘量不對小於int的整數型別執行算術運算,可能溢位和涉及整型提升

  • 如果要利用整數溢位,必須使用無符號數

參考

相關文章