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


概述

無符號數和有符號數是通用的計算機概念,具體到編程語言上則各有各的不同,程序員是解決實際問題的,所以必須熟悉編程語言中的整數。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的整數類型執行算術運算,可能溢出和涉及整型提升

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

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM