C++ 多型:靜態(template) vs 動態(virtual)
多型就是「同一個介面、不同行為」。C++ 有兩條路:動態多型(virtual,執行期決定呼叫誰)跟 靜態多型(template / CRTP,編譯期就決定)。 直接看例子(範例用到 C++23 的 std::println,include 多半省略)。
動態多型:virtual
struct Shape { virtual double area() const = 0; virtual ~Shape() = default;};
struct Circle : Shape { double r; Circle(double r) : r(r) {} double area() const override { return 3.14159 * r * r; }};struct Square : Shape { double s; Square(double s) : s(s) {} double area() const override { return s * s; }};
void print_area(const Shape& sh) { // 不知道具體型別 std::println("{}", sh.area()); // 執行期經 vtable 分派}重點是異質容器——同一個 vector 裝不同型別,執行期才決定呼叫誰:
std::vector<std::unique_ptr<Shape>> shapes;shapes.push_back(std::make_unique<Circle>(2.0));shapes.push_back(std::make_unique<Square>(3.0));for (auto& s : shapes) print_area(*s); // 2 種 area() 各自被叫到代價:每次呼叫多一層 vtable 間接、難 inline,每個物件多一個 vptr。
靜態多型:template
沒有共同基底、沒有 virtual,靠 template 在編譯期綁定:
struct Circle { double r; double area() const { return 3.14159 * r * r; } };struct Square { double s; double area() const { return s * s; } };
template <typename T>void print_area(const T& sh) { // 編譯期就綁定 T::area std::println("{}", sh.area());}
print_area(Circle{2.0});print_area(Square{3.0});可 inline、零執行期分派成本。代價:每個 T 各生一份程式碼, 而且型別得編譯期已知——塞不進同一個容器。
靜態多型:CRTP
想要「基底定義共通介面」又不付 virtual 成本, 就讓基底吃下衍生型別當模板參數(CRTP):
template <typename Derived>struct Shape { double area() const { // 編譯期 static_cast 到真正型別,無 vtable return static_cast<const Derived&>(*this).area_impl(); }};
struct Circle : Shape<Circle> { double r; double area_impl() const { return 3.14159 * r * r; }};
Circle c{2.0};c.area(); // Shape<Circle>::area() → Circle::area_impl(),全程編譯期基底叫 area、衍生叫 area_impl 是故意的——同名的話 static_cast 那行會綁回衍生的 area,變成無限遞迴。 常用在 header-only 的泛型基底;缺點一樣:Shape<Circle> 跟 Shape<Square> 是不同型別,不能混在一個容器。
concept:把介面講清楚
靜態多型的老問題是「型別不符時錯誤訊息很長」。C++20 的 concept 可以先把要求寫明,錯誤就直接指到點上:
#include <concepts>
template <typename T>concept HasArea = requires(const T t) { { t.area() } -> std::convertible_to<double>;};
template <HasArea T>void print_area(const T& sh) { std::println("{}", sh.area()); }
print_area(Circle{2.0}); // OK// print_area(42); // 編譯錯誤:int 不滿足 HasArea(訊息清楚)第三條路:std::variant + std::visit
如果型別集合是封閉的(就那幾種、編譯期已知), 還有第三條路:用 std::variant 裝值、std::visit 分派。 能放進同一個容器,又不用繼承或指標:
#include <variant>
struct Circle { double r; double area() const { return 3.14159 * r * r; } };struct Square { double s; double area() const { return s * s; } };
using Shape = std::variant<Circle, Square>;
std::vector<Shape> shapes{ Circle{2.0}, Square{3.0} };
for (const auto& sh : shapes) std::println("{}", std::visit([](const auto& s) { return s.area(); }, sh));值語意、無 heap 配置;分派在編譯期決定、沒有 vtable,執行期只剩一次依 index() 的跳轉。注意這裡用泛型 lambda 不會做完整性檢查—— 新增的型別只要有 area() 就默默通過;想要「漏一種就編譯報錯」, 得把 visitor 寫成每個型別一個 overload。代價是集合封閉,加一種就得改 variant 定義。
什麼時候用哪個
- 動態(virtual):型別執行期才知道、需要異質容器(
vector<Base*>)、跨外掛或 ABI 邊界。 代價是 vtable 間接 + vptr、難 inline。 - 靜態(template / CRTP):型別編譯期已知、效能敏感的熱路徑、 header-only 泛型庫。代價是程式碼膨脹、編譯變慢、二進位介面不穩。
- 封閉集合(std::variant):型別就那幾種、編譯期已知, 又想放進同一個容器。值語意、無 vtable,但加型別就得改定義。
一句話:開放集合用 virtual、封閉集合用 variant、熱路徑用 template。