← Back to Blog
C++Performance

C++ 多型:靜態(template) vs 動態(virtual)

多型就是「同一個介面、不同行為」。C++ 有兩條路:動態多型virtual,執行期決定呼叫誰)跟 靜態多型(template / CRTP,編譯期就決定)。 直接看例子(範例用到 C++23 的 std::println,include 多半省略)。

動態多型:virtual

cpp
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 裝不同型別,執行期才決定呼叫誰:

cpp
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 在編譯期綁定:

cpp
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):

cpp
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 可以先把要求寫明,錯誤就直接指到點上:

cpp
#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 分派。 能放進同一個容器,又不用繼承或指標:

cpp
#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