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) * 8:先把 a 拓寬到 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]$。把 127(0111 1111)再加 1,位元變成 1000 0000,這個 pattern 在補數裡正是 -128——數線從最大正數「環繞」回最小負數。這就是「溢位回繞」的真相:整數運算其實是模 $2^n$ 的環狀算術,並沒有真正的「無限大」,只有一個首尾相接的圈。
一個常被誤解的點:有號整數溢位在 C++ 中是未定義行為(undefined behavior, UB),而無號整數溢位是良好定義的(保證做模 $2^n$ 運算)。這意味著編譯器在最佳化時,可以假設有號整數永遠不溢位——它會據此推論並改寫你的程式碼,導致溢位後的行為比「單純回繞」更難預測。所以「回繞成負數」只是常見的觀察結果,不是你能依賴的契約。
算術轉換:運算式裡的隱形升級規則
入門篇提過 7 / 2 和 7.0 / 2 的差別,也提過無號/有號比較的陷阱。但這些其實都是同一套機制的表象——整數提升(integer promotion)與慣常算術轉換(usual arithmetic conversions)。掌握這套規則,你才能在看到一個運算式時,心算出每一步的中間型別。
規則可以簡化為兩步:
- 整數提升:任何比
int窄的整數型別(char、short、bool、uint8_t等),只要參與算術運算,會先被提升為int(若int裝得下)。 - 慣常算術轉換:當兩個運算元型別不同,編譯器依「等級(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。錯了。 因為整數提升先把 x、y 都提升為 int(32 位元),加法在 int 的世界裡進行,結果是 300,z 的型別是 int,sizeof(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 * d:unsigned int與double運算 →u轉成double→10.0 * 2.0 = 20.0(型別double)。c + (20.0):c先整數提升為int(值 65),再與double運算 →65轉成65.0→65.0 + 20.0 = 85.0(型別double)。85.0 - 5:5是int,轉成double→85.0 - 5.0 = 80.0(型別double)。
所以 r 是 double,值 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.0 或 std::sqrt(-1.0))。NaN 有個詭異性質:它不等於任何值,包括它自己。nan == nan 是 false。要偵測得用 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::setprecision、std::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} 表示十六進位含前綴。相比舊式 printf,std::format 在編譯期就檢查格式字串與引數型別是否匹配——又一個「把錯誤鎖在編譯期」的現代設計。printf("%d", 3.14) 這種型別不符在舊 C 裡是 UB,在 std::format 裡則是編譯錯誤。
重點回顧
- 整數是一個寬度家族,標準只保證大小排序與最小範圍。要可攜,用
<cstdint>的int32_t、int64_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):
struct與std::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++ 就不再是背一堆零碎規則,而是看見一個始終在問同一個問題的語言:這個錯誤,能不能讓編譯器在你按下執行鍵之前就替你攔下來?