class { ... }想成一個實體盒子,認為大括號裡寫的類型、變數和函數,最後都要一起塞進每個物件內部的 0/1 裡。--這是錯誤的
類別宣告是編譯器閱讀的一份規則文件,不是一整塊會原樣存在於執行期記憶體中的資料。Epic 也把 class 描述成建立 Object/Actor 的模板;類別標頭裡是「宣告」函數與屬性,不是說每個物件都攜帶一份函數。(Epic Games Developers)
看這個:
struct FVectorLike { using Scalar = float; float X; float Y; float Z; float LengthSquared() const { return X * X + Y * Y + Z * Z; } };你在源碼中看到它們都寫在同一對{}裡,但編譯後會被分流到完全不同的地方。
FVectorLike 的宣告 ├─ using Scalar = float │ └─ 只給編譯器建立一個類型別名 │ └─ FVectorLike 物件裡不佔空間 │ ├─ float X, Y, Z │ └─ 決定每個物件的資料布局 │ └─ 每個 FVectorLike 物件真正擁有三個 float │ └─ LengthSquared() └─ 編譯成一份機器指令 └─ 所有 FVectorLike 物件共同使用一個物件真正存了什麼
例如:
FVectorLike A; FVectorLike B;執行期可以近似理解成:
A 的記憶體 ├─ X:4 bytes ├─ Y:4 bytes └─ Z:4 bytes B 的記憶體 ├─ X:4 bytes ├─ Y:4 bytes └─ Z:4 bytes在典型 Windows C++ 實作中,它們通常各自是 12 bytes;實際布局仍由型別、對齊及編譯器 ABI 決定。C++ 所謂 object layout,主要就是描述資料成員如何排列在物件記憶體裡。(Microsoft Learn)
它們不會變成:
A ├─ X ├─ Y ├─ Z ├─ Scalar 類型資訊 └─ LengthSquared 函數的全部機器碼函數沒有複製進 A,也沒有複製進 B。
!!!那A.LengthSquared()怎麼知道操作 A?
這是關鍵。
你寫:
float Result = A.LengthSquared();概念上,編譯器可以把它理解成:
float Result = FVectorLike_LengthSquared(&A);也就是函數只有一份,但呼叫時偷偷多傳入:
this = &A;因此原來的:
return X * X + Y * Y + Z * Z;概念上等於:
return this->X * this->X + this->Y * this->Y + this->Z * this->Z;C++ 的非靜態成員函數具有隱含的this指標,指向目前正在被操作的物件。(Microsoft Learn)
所以:
A.LengthSquared(); B.LengthSquared();使用的是同一份函數機器碼,區別只在傳進去的物件地址:
呼叫 A.LengthSquared() └─ this = A 的地址 呼叫 B.LengthSquared() └─ this = B 的地址這就是「資料屬於每個物件,行為由所有物件共用」。
「類別裡可以存類型」也不是把類型塞進物件
例如:
struct FContainer { using ValueType = float; struct FIterator { int Index; }; ValueType Value; };這裡:
FContainer ├─ ValueType │ └─ 一個作用域內的類型名稱 │ ├─ FIterator │ └─ 另一個類型的宣告 │ └─ Value └─ 真正的資料成員你可以寫:
FContainer::ValueType Number; FContainer::FIterator Iterator;FContainer::在這裡更像一個名稱作用域:
FContainer └─ 裡面有哪些名稱 ├─ ValueType ├─ FIterator └─ Value巢狀類型本身不會增加FContainer物件大小。
只有你真的寫:
struct FContainer { FIterator Iterator; };這時Iterator才是物件資料,才會佔記憶體。
因此要區分:
struct FIterator {}; // 宣告一種類型 FIterator Iterator; // 放置一個該類型的物件第一行只是讓編譯器知道「有這種形狀」。
第二行才是真的要求:
按照
FIterator的布局,給每個FContainer實例留出一塊記憶體。
最底層的 0/1 到底怎麼區分資料、類型和函數
0 和 1 本身沒有「這是 float」「這是函數」的天然標籤。
同一串 bits:
01000000 01001001 00001111 11011011只有在不同解讀規則下,才可能被理解成:
└─ 當 float 解讀 └─ 一個浮點數 └─ 當 int32 解讀 └─ 一個整數 └─ 當機器指令的一部分解讀 └─ 某些 CPU opcode / operand意義來自「誰在解讀」以及「按什麼規則解讀」。
完整鏈路是:
C++ 源碼文字 └─ 編譯器解析 ├─ 類型宣告 │ └─ 用於檢查尺寸、成員偏移、呼叫是否合法 │ ├─ 資料成員 │ └─ 形成物件的記憶體布局 │ └─ 函數 └─ 編譯成 CPU 機器指令 可執行檔 ├─ Code / .text │ └─ 函數的機器指令 │ ├─ Data │ └─ 全域與靜態資料 │ └─ 編譯器與連結器資訊 └─ 符號、重定位、除錯資訊等 執行時 ├─ CPU 指令指標指向 Code │ └─ CPU 把那些 bytes 當指令執行 │ └─ Load / Store 指令指向物件記憶體 └─ CPU 把那些 bytes 當資料讀寫不是 bits 自己聲稱「我是函數」。
而是:
CPU 從指令指標位置取 bytes └─ 按指令集解碼 └─ 它們成為函數指令 CPU 執行讀取 float 的指令 └─ 從某地址取 4 bytes └─ 按浮點格式使用!!!函數如果是虛函數,則以base類裡的所有Virtual函數給這個類湊成"表"
例如:
struct Base { virtual void F(); virtual void G(); }; struct ChildA : Base { void F() override; }; struct ChildB : Base { void G() override; };概念上的布局是:
Base 的表 ├─ slot 0 → Base::F └─ slot 1 → Base::G ChildA 的表 ├─ slot 0 → ChildA::F └─ slot 1 → Base::G ChildB 的表 ├─ slot 0 → Base::F └─ slot 1 → ChildB::G不是:
一張總表 ├─ Base::F ├─ ChildA::F ├─ ChildB::F ├─ Base::G ├─ ChildA::G └─ ChildB::G而是每個類型都有一套固定槽位,槽位代表虛函數接口,槽位裡放的是:
對這個具體類型而言,該虛函數最終應該調用哪個實現。
Microsoft 的資料也把 vtable 描述為函數指標構成的表;虛呼叫會從固定 offset 的槽位讀出函數地址再調用。(Microsoft Learn)
所以class { ... }究竟是什麼
不要再把它理解成「一個裡面裝了資料和函數的盒子」。
更準確是:
class / struct ├─ 一個名稱作用域 │ ├─ 可放類型名稱 │ ├─ 可放函數名稱 │ └─ 可放變數名稱 │ ├─ 一份物件布局規則 │ └─ 非 static 資料成員決定物件裡有哪些 bytes │ ├─ 一組可作用於該物件的函數 │ └─ 通過 this 指標找到具體物件 │ └─ 一套存取與型別檢查規則 ├─ public ├─ protected └─ private它不是單一物理實體,而是 C++ 把幾種相關規則收束到同一個名字下。
最終可以壓成一句:
類別大括號裡混合的是「宣告」,不是同一種執行期資料;編譯器會把類型規則留在編譯期,把資料成員變成物件布局,把函數變成共用機器碼,再用
this把函數接到具體物件。
它們根本沒有作為同一坨 0/1 被塞進物件;只是在源碼層被放進同一個語義作用域。