Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

變數、型別與輸入輸出

C++ 型別、整數與輸入輸出進階:當「正確的程式」給出錯誤答案

深入固定寬度整數、算術轉換規則、IEEE-754 浮點位元、串流狀態與 C++20 的型別安全演進

一個「正確的程式」為什麼會給出錯誤答案?

請先看一段看起來無懈可擊的程式碼。它讀進一個檔案中的位元組總數,乘以 8 換算成位元(bit)數,再印出來:

#include <iostream>

int main() {
    int bytes = 300'000'000;       // 三億位元組,約 286 MB
    int bits = bytes * 8;          // 換算成 bit
    std::cout << bits << "\n";
    return 0;
}

語法全對、編譯零警告(在某些設定下)、邏輯也直觀。但它印出來的是一個負數-1894967296。沒有當機、沒有例外、沒有任何提示——程式平靜地給了你一個天文數字級的錯誤答案。

入門篇我們學過「int 會溢位」,但只說它「會回繞成負數」。這篇進階文章要回答的是更深的問題:為什麼會這樣?回繞到底依什麼規則?什麼時候 bytes * 8 裡的 bytes 已經悄悄被轉成了別的型別?以及——當你寫的是會被別人依賴的程式時,怎麼讓型別系統真正替你扛住這些事。我們會深入整數的固定寬度模型、算術轉換的隱藏規則、IEEE-754 浮點數的位元真相,以及現代 C++ 把輸入輸出與型別安全推向何處。

變數、型別與輸入輸出進階概念示意圖

整數其實不是一種型別,而是一整套寬度模型

入門時我們把 int 當成「整數」,彷彿只有一種。實際上 C++ 的整數是一個家族,每個成員有自己的寬度(位元數)與正負性(signedness)。而且——這點常被忽略——標準並未規定 int 一定是 4 bytes。標準只保證最小範圍與大小排序:

$$\text{sizeof(char)} \le \text{sizeof(short)} \le \text{sizeof(int)} \le \text{sizeof(long)} \le \text{sizeof(long long)}$$

這代表你以為穩定的 int = 4 bytes 其實是「在主流桌面/伺服器平台上恰好成立」的慣例,不是語言保證。要寫真正可攜(portable)的程式,現代 C++ 提供了 <cstdint>固定寬度整數型別,名稱直接把位元數寫出來:

#include <cstdint>
#include <iostream>

int main() {
    std::int32_t  a = 300'000'000;   // 保證 32 位元有號
    std::int64_t  bits = static_cast<std::int64_t>(a) * 8;  // 升到 64 位元再乘
    std::uint8_t  flags = 0b1010'1100;  // 保證 8 位元無號
    std::cout << bits << "\n";        // 2400000000,正確!
    return 0;
}

關鍵在 static_cast<std::int64_t>(a) * 8a 拓寬到 64 位元,做乘法,這樣中間結果就有足夠的位元容納 24 億。開頭那段 bug 程式的真正錯誤,不是「最後存進 int 不夠大」,而是「乘法當下就是在 32 位元的世界裡進行的」——溢位發生在運算的瞬間,存到哪裡已經來不及補救。

固定寬度型別還有一組「至少這麼寬」的變體:int_least32_t(至少 32 位元)、int_fast32_t(至少 32 位元,但選平台上跑最快的)。當你在意的是「夠用」而非「剛好」,這些更能表達意圖。

補數:負數在記憶體裡長什麼樣

要理解溢位為何回繞成負數,得看有號整數的位元表示法。自 C++20 起,標準正式規定有號整數使用二的補數(two's complement)表示(在此之前是「實作定義」,但所有現代硬體早就都用補數)。

以 8 位元為例,最高位是符號位。正數就是平常的二進位;負數則是「把對應正數的所有位元取反再加一」:

  5  =  0000 0101
 -5  =  1111 1011   (5 取反 1111 1010,加一 1111 1011)

8 位元有號數的範圍是 $[-128, 127]$,也就是 $[-2^7, 2^7 - 1]$。把 1270111 1111)再加 1,位元變成 1000 0000,這個 pattern 在補數裡正是 -128——數線從最大正數「環繞」回最小負數。這就是「溢位回繞」的真相:整數運算其實是模 $2^n$ 的環狀算術,並沒有真正的「無限大」,只有一個首尾相接的圈。

一個常被誤解的點:有號整數溢位在 C++ 中是未定義行為(undefined behavior, UB),而無號整數溢位是良好定義的(保證做模 $2^n$ 運算)。這意味著編譯器在最佳化時,可以假設有號整數永遠不溢位——它會據此推論並改寫你的程式碼,導致溢位後的行為比「單純回繞」更難預測。所以「回繞成負數」只是常見的觀察結果,不是你能依賴的契約。

算術轉換:運算式裡的隱形升級規則

入門篇提過 7 / 27.0 / 2 的差別,也提過無號/有號比較的陷阱。但這些其實都是同一套機制的表象——整數提升(integer promotion)慣常算術轉換(usual arithmetic conversions)。掌握這套規則,你才能在看到一個運算式時,心算出每一步的中間型別

規則可以簡化為兩步:

  1. 整數提升:任何比 int 窄的整數型別(charshortbooluint8_t 等),只要參與算術運算,會先被提升為 int(若 int 裝得下)。
  2. 慣常算術轉換:當兩個運算元型別不同,編譯器依「等級(rank)」把較低的轉成較高的,使兩者一致後才運算。大致順序是 int < unsigned < long < ... < double < long double;浮點優先於整數。

來看一個會讓很多人跌倒的例子:

#include <cstdint>
#include <iostream>

int main() {
    std::uint8_t x = 200;
    std::uint8_t y = 100;
    auto z = x + y;          // z 是什麼型別?值是多少?
    std::cout << z << " " << sizeof(z) << "\n";
    return 0;
}

直覺會說:兩個 8 位元無號數相加,200 + 100 = 300,超過 8 位元上限 255,應該回繞成 44錯了。 因為整數提升先把 xy 都提升為 int(32 位元),加法在 int 的世界裡進行,結果是 300z 的型別是 intsizeof(z) 是 4。沒有任何回繞。

這個例子的教訓很深:C++ 的算術「不在你宣告的窄型別裡發生」,而是先升格到至少 int 才動手。回繞只在你把結果存回窄型別時才會出現。理解這點,你就能解釋為什麼前面那個 bytes * 8 不會因為「bytes 是 int」而自動變大——int 已經是提升的終點,乘法就卡在 32 位元了。

動手算一下:追蹤每一步的型別

請你像編譯器一樣,逐步推導下面這個運算式的型別與值:

char c = 'A';                  // 'A' 的 ASCII 是 65
unsigned int u = 10;
double d = 2.0;

auto r = c + u * d - 5;

一步步來:

  • u * dunsigned intdouble 運算 → u 轉成 double10.0 * 2.0 = 20.0(型別 double)。
  • c + (20.0)c 先整數提升為 int(值 65),再與 double 運算 → 65 轉成 65.065.0 + 20.0 = 85.0(型別 double)。
  • 85.0 - 55int,轉成 double85.0 - 5.0 = 80.0(型別 double)。

所以 rdouble,值 80.0。注意 'A' 從頭到尾被當成數字 65 在算——char 本質是小整數,這個特性讓「字元算術」(如 c - 'A' 求字母序號)成為可能,但也意味著你若不小心把字元拿去做算術,編譯器不會攔你。

浮點數的位元真相:為什麼 0.1 加不準

入門篇告誡過「別用 == 比浮點數」,並示範了 0.1 + 0.2 != 0.3。進階版本要問:這個誤差具體有多大?它從哪個位元跑出來的?

double 遵循 IEEE-754 雙精度標準,用 64 個位元切成三段:

位元數 作用
符號 sign 1 正負
指數 exponent 11 決定數值的數量級(科學記號的次方)
尾數 mantissa/fraction 52 有效數字的精度

它表示的值大致是 $(-1)^{s} \times 1.f \times 2^{e - 1023}$。重點在於:尾數是二進位小數。而十進位的 0.1,換成二進位是無限循環小數 $0.0001100110011\ldots_2$,就像十進位寫不出 $1/3$ 一樣。52 位尾數只能截斷儲存,於是 0.1 在記憶體裡其實是一個略大於 0.1 的近似值。三個近似值相加,誤差累積,結果就和真正的 0.3 差了大約 $1.1 \times 10^{-16}$——剛好落在 double 約 15–16 位十進位有效數字的精度極限附近。

#include <iostream>
#include <iomanip>

int main() {
    double r = 0.1 + 0.2;
    std::cout << std::setprecision(20) << r << "\n";
    // 0.30000000000000004441
    return 0;
}

那段拖在後面的 ...004441 不是 bug,是二進位浮點的結構性特徵。所以正確的浮點比較要看「相對誤差」而非絕對誤差,因為浮點的精度是隨數量級縮放的:

#include <cmath>
#include <algorithm>

bool nearly_equal(double a, double b, double eps = 1e-9) {
    double diff = std::fabs(a - b);
    double scale = std::max(std::fabs(a), std::fabs(b));
    return diff <= eps * std::max(1.0, scale);   // 依數量級縮放容忍度
}

還有兩個入門少談的「特殊值」也由 IEEE-754 定義,且會在運算中無聲產生:infinity(如 1.0 / 0.0,浮點除以零不會當機,而是給 inf)與 NaN(Not a Number,如 0.0 / 0.0std::sqrt(-1.0))。NaN 有個詭異性質:它不等於任何值,包括它自己nan == nanfalse。要偵測得用 std::isnan(x)。這也是為什麼用浮點比較時,NaN 會讓所有 <>== 全部回 false,悄悄破壞你的排序或邏輯。

輸入輸出的進階面:串流是有狀態的機器

入門篇把 cin >>cout << 當成「讀」與「印」。進階視角要把它們看成有狀態的串流物件——std::cin 內部維護著一組狀態旗標,一旦讀取失敗就會「卡住」,後續所有讀取都直接失效。這是新手最常掉進去、卻最難自己診斷的坑。

#include <iostream>

int main() {
    int n;
    std::cout << "輸入一個整數:";
    std::cin >> n;
    if (!std::cin) {                 // 等同 std::cin.fail()
        std::cout << "讀取失敗,輸入不是合法整數\n";
        std::cin.clear();            // 清除錯誤旗標
        std::cin.ignore(10000, '\n');// 丟棄殘留的壞輸入直到換行
    }
    std::cout << "n = " << n << "\n";
    return 0;
}

當使用者對 >> n 輸入了 "abc",擷取失敗,cin 進入 fail 狀態,n 在 C++11 後被設為 0,而且——重點——壞掉的 "abc" 還留在輸入緩衝區裡。如果你不 clear()ignore(),後面每個 >> 都會立刻失敗,造成「程式好像跳過了所有輸入」的鬼打牆。把 if (!std::cin) 當成 I/O 後的標準檢查,是寫穩健輸入處理的起點。

>>getline:空白字元的分野

另一個經典陷阱是混用 >>std::getline>>跳過前導空白並在遇到下一個空白(空格、tab、換行)時停止;getline 則讀整行(含空白)直到換行。兩者混用時,>> 留在緩衝區的那個換行符會被緊接的 getline 當成「空行」立刻讀走:

#include <iostream>
#include <string>

int main() {
    int age;
    std::string name;
    std::cin >> age;                          // 讀數字,換行留在緩衝區
    std::cin.ignore();                        // 關鍵:丟掉那個換行
    std::getline(std::cin, name);             // 才能正確讀整行姓名
    std::cout << name << " is " << age << "\n";
    return 0;
}

少了那行 std::cin.ignore()name 會是空字串。這不是 bug,是兩種讀取語意的交界處——理解串流如何看待空白,你才有辦法自己 debug 這類問題。

現代輸出格式化:從操縱子到 std::format

入門用 std::setprecisionstd::boolalpha 這類操縱子(manipulator)控制輸出,但操縱子的問題是有些會「黏住」串流狀態(如 setprecision 一設就持續生效),且語法冗長。C++20 引入了 std::format,借鑑 Python 的 f-string 風格,型別安全又好讀:

#include <format>
#include <iostream>

int main() {
    double pi = 3.14159265;
    int n = 42;
    std::cout << std::format("pi≈{:.3f}, n={:>5}, hex={:#x}\n", pi, n, n);
    // pi≈3.142, n=   42, hex=0x2a
    return 0;
}

{:.3f} 表示小數三位、{:>5} 表示靠右對齊寬度 5、{:#x} 表示十六進位含前綴。相比舊式 printfstd::format編譯期就檢查格式字串與引數型別是否匹配——又一個「把錯誤鎖在編譯期」的現代設計。printf("%d", 3.14) 這種型別不符在舊 C 裡是 UB,在 std::format 裡則是編譯錯誤。

重點回顧

  • 整數是一個寬度家族,標準只保證大小排序與最小範圍。要可攜,用 <cstdint>int32_tint64_t 等固定寬度型別;運算前先 static_cast 拓寬,溢位才不會在中途發生。
  • 算術前有整數提升(窄型別先升 int)與慣常算術轉換(向高等級型別靠攏)。uint8_t + uint8_t 的結果其實是 int,不會回繞。
  • 有號整數用二的補數表示,溢位是模 $2^n$ 的環狀算術;有號溢位是 UB、無號溢位有定義。
  • double 是 IEEE-754:二進位尾數無法精確表示多數十進位小數,誤差約在第 16 位有效數字;NaN 不等於自己,浮點比較要用相對誤差。
  • cin有狀態的串流,讀取失敗會卡住,需 clear() + ignore();混用 >>getline 要處理殘留換行;現代格式化優先用 C++20 的 std::format

深入探討(研究所視角)

物件模型、值表示與型別雙關(type punning)

到目前為止我們談的「型別」都是抽象層次的,但研究所層級要把它接回記憶體模型。在 C++ 的抽象機器(abstract machine)裡,每個物件占據一段連續的位元組,這段位元組可以從兩個角度看:物件表示(object representation)——構成它的原始位元組序列;以及值表示(value representation)——這些位元組所「意指」的數值。對 int 這兩者通常一致,但對某些型別(含 padding 的結構、bool)可能存在不參與值的位元,產生所謂的 trap representation 或填充位元。

由此衍生出 C++ 最微妙的規則之一:嚴格別名規則(strict aliasing rule)。它規定,你不能透過一個與物件真實型別不相容的指標/參考去存取該物件(少數例外如 char*std::byte*)。下面這種「把 float 的位元當成 int 來看」的把戲——type punning——是 UB:

float f = 1.0f;
int*  p = reinterpret_cast<int*>(&f);
int   bits = *p;          // 未定義行為!違反嚴格別名

為什麼語言要這樣綁手綁腳?因為嚴格別名是編譯器最佳化的根基之一。如果編譯器能假設「不同型別的指標永不指向同一塊記憶體」,它就能大膽地把載入(load)的值快取在暫存器、重排記憶體存取,產生更快的程式碼。換句話說,型別系統在這裡不只是正確性工具,更是效能契約——你遵守它,編譯器才敢替你最佳化。

那麼合法地檢視位元怎麼做?C++20 給了 std::bit_cast,它在編譯期保證來源與目標大小相同、且做的是位元拷貝而非重新詮釋指標,完全避開別名問題:

#include <bit>
#include <cstdint>

float f = 1.0f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(f);  // 合法、可在 constexpr 用

這正是工程上「想做的事」與「型別系統允許的事」如何透過新工具被重新調和的範例——bit_cast 把一個歷史上充滿 UB 的常見需求,收編成型別安全、最佳化友善的標準操作。

型別作為集合:靜態型別系統的理論底座

把視野拉到型別理論(type theory),一個型別可被理解為「一組值的集合」加上「一組合法操作」。bool 是 $\{true, false\}$、uint8_t 是 $\{0, 1, \ldots, 255\}$。從這個角度,C++ 近年加入的工具其實是在豐富「如何組合型別」的語彙:

  • 積型別(product type)structstd::tuple 把多個型別「乘」在一起,值的數量是各成員值數量的乘積。C++17 的結構化繫結(structured bindings)讓你一次解構它們:auto [x, y] = make_point();
  • 和型別(sum type)std::variant<int, double, std::string> 表示「是這幾種型別之一」,值的數量是各型別之和。配合 std::visit,編譯器能靜態檢查你是否處理了所有可能型別——這是把「遺漏分支」這類執行期錯誤前移到編譯期的又一例。
  • 可選型別(option type)std::optional<int> 表示「一個 int 或什麼都沒有」,型別安全地取代了「用 -1 或 nullptr 代表無效值」這種容易出錯的慣例。
#include <variant>
#include <string>
#include <iostream>

std::variant<int, std::string> parse(bool ok) {
    if (ok) return 42;
    return std::string{"error"};
}

int main() {
    auto v = parse(true);
    std::visit([](auto&& x){ std::cout << x << "\n"; }, v);  // 編譯期確保涵蓋所有型別
    return 0;
}

這呼應入門篇結尾的主題:C++ 型別系統的演進史,幾乎就是一部「不斷把更多種類的錯誤從執行期搬到編譯期」的歷史。從早期相容 C 的寬鬆隱式轉換,到 static_cast 的明確意圖、{} 的窄化禁止、std::format 的編譯期格式檢查、std::variant 的窮盡性、std::bit_cast 的安全位元操作——每一步都在同一條軸線上推進。理解這條軸線,你看待 C++ 就不再是背一堆零碎規則,而是看見一個始終在問同一個問題的語言:這個錯誤,能不能讓編譯器在你按下執行鍵之前就替你攔下來?

AI 共讀助教正在陪你讀:C++ 型別、整數與輸入輸出進階:當「正確的程式」給出錯誤答案
嗨!我是這篇文章的共讀助教,只根據〈C++ 型別、整數與輸入輸出進階:當「正確的程式」給出錯誤答案〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。