Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

變數、型別與輸入輸出

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++ 型別、整數與輸入輸出進階:當「正確的程式」給出錯誤答案〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。