第 4 章 运行期选择机制(RTS):OpenFOAM 的"插件式"对象工厂
OpenFOAM 里充斥着这样的配置——
turbulenceProperties写RAS { RASModel kOmegaSST; },fvSolution写p { solver PCG; preconditioner DIC; },0/p写type fixedValue;。这些字符串如何在运行期变成一个真正的 C++ 对象?答案就是本章的主角:运行期选择表(Run-Time Selection Table,RTS)。它是 OpenFOAM 最精妙、也最容易被初学者忽略的机制——理解它,就理解了 OpenFOAM 为什么"可插拔"。
4.1 问题的本质
考虑湍流模型:k-ε、k-ω SST、Spalart-Allmaras、Smagorinsky……几十个类,都在编译期存在。但某个具体算例用哪个,只有在运行期读 turbulenceProperties 字典时才知道。这是经典的抽象工厂模式问题。
C++ 的常规解法(虚函数 + 手写 if/else 字符串映射)在这里不够用,原因有三:
- 模型分布在多个库/文件里,不可能在工厂函数里集中
#include所有派生类; - 要支持运行期动态加载(
dlopen一个额外的.so就多一批模型); - 模式要能复用——边界条件、求解器、格式、物性都想要同一套机制。
OpenFOAM 的解法是:每个派生类在"静态初始化期"把自己注册进基类的一张全局表;基类通过一个 New(dict, name) 函数按名字查表构造。 全部由三个宏完成:declare / define / add。
4.2 三个宏的分工
| 宏 | 写在 | 作用 |
|---|---|---|
declareRunTimeSelectionTable | 基类头文件(类内) | 声明构造表类型、单例指针、查找函数、add 助手类 |
defineRunTimeSelectionTable | 基类源文件 | 为上述声明提供存储与实现的实例化 |
addToRunTimeSelectionTable | 派生类源文件(全局作用域) | 用一个静态对象把自己注册进表 |
以及模板版本:declareRunTimeSelectionTable 的 declareRunTimeNewSelectionTable、defineTemplateRunTimeSelectionTable、addTemplateToRunTimeSelectionTable / addTemplatedToRunTimeSelectionTable 等,用于模板基类。
4.3 declareRunTimeSelectionTableBase:宏展开后的真相
src/OpenFOAM/db/runTimeSelection/construction/runTimeSelectionTables.H 第 54–88 行是最核心的公共基础设施。它接收三个参数:returnType(返回的智能指针包装类型)、prefix(表名前缀,通常等于参数名如 patch/dictionary)、argList(构造器参数列表)。逐条展开:
#define declareRunTimeSelectionTableBase(returnType,prefix,argList) \
\
/* Function pointer type: construct from argList */ \
typedef returnType (*prefix##Ptr)argList; \
\
/* Function table type: construct from argList */ \
typedef ::Foam::HashTable \
< \
prefix##Ptr, \
::Foam::word, \
::Foam::Hash<::Foam::word> \
> prefix##TableType; \
\
typedef ::Foam::HashTable \
< \
std::pair<::Foam::word,int>, \
::Foam::word, \
::Foam::Hash<::Foam::word> \
> prefix##CompatTableType; \
\
static prefix##TableType* prefix##TablePtr_; \
static std::unique_ptr<prefix##CompatTableType> prefix##CompatTablePtr_; \
static prefix##CompatTableType& prefix##CompatTable(); \
static void prefix##TablePtr_construct(bool load); \
static prefix##Ptr prefix##Table(const ::Foam::word& k)
解读:
<prefix>Ptr是函数指针类型——"从一个argList构造出returnType"。例如对于边界条件patch表,patchPtr就是tmp<fvPatchField<Type>> (*)(const fvPatch&, const DimensionedField<Type, volMesh>&)。<prefix>TableType是HashTable<函数指针, word>——一个从"字符串名字"到"构造函数指针"的映射。这就是那张全局注册表。<prefix>CompatTableType是HashTable<pair<word,int>, word>——从"废弃名"到"新名 + 版本号"的别名表。用于向后兼容:老字典写RNGkEpsilon,可以自动映射到新名,并给出弃用告警。<prefix>TablePtr_:表的单例指针(裸指针,静态存储)。<prefix>CompatTablePtr_:别名表的单例指针(unique_ptr,自动清理,见源码注释uses automatic cleanup for compat table (issue #2314))。<prefix>Table(k):查找函数,返回函数指针,找不到返回nullptr。
4.4 defineRunTimeSelectionTableBase:定义存储与查找
第 93–162 行把上面的声明落地成定义。其中几个设计细节非常值得品味。
4.4.1 单例的"懒惰构造 + 显式析构"
Tspecialize void prefix##TablePtr_construct(bool load) \
{ \
static bool constructed = false; \
if (load) \
{ \
if (!constructed) \
{ \
prefix##TablePtr_ = new prefix##TableType; \
constructed = true; \
} \
} \
else if (prefix##TablePtr_) \
{ \
delete prefix##TablePtr_; \
prefix##TablePtr_ = nullptr; \
} \
}
为什么要这样设计?因为 C++ 的静态初始化顺序不确定。表本身是一个全局单例,而注册它的派生类也都是全局对象;谁先谁后无法保证。OpenFOAM 的解决办法是:创建表时用 constructed 静态布尔保证幂等,销毁时机则由每个 add 助手的析构函数在最后一个注册者析构时触发(见 4.5)。这样无论静态初始化顺序如何,表都能正确建立与回收。
4.4.2 查找:先精确名,再兼容名
Tspecialize prefix##Ptr prefix##Table(const ::Foam::word& k) \
{ \
if (prefix##TablePtr_) \
{ \
const auto& tbl = *prefix##TablePtr_; \
auto iter = tbl.cfind(k); \
if (!iter.good() && prefix##CompatTablePtr_) \
{ \
const auto altIter = prefix##CompatTablePtr_->cfind(k); \
if (altIter.good()) \
{ \
const auto& alt = altIter.val(); /* <word,int> */ \
iter = tbl.cfind(alt.first); \
if (::Foam::error::warnAboutAge(alt.second)) \
{ \
std::cerr \
<< "Using [v" << alt.second << "] '" << k \
<< "' instead of '" << alt.first \
<< "' in selection table: " \
<< #baseType << '\n' << std::endl; \
::Foam::error::warnAboutAge("lookup", alt.second); \
} \
} \
} \
if (iter.good()) \
{ \
return iter.val(); \
} \
} \
return nullptr; \
}
查找逻辑:先在主表按名查;查不到再查"兼容表",若命中则用新名再查一次,并按年龄给出弃用提示。这是一套在运行期生效的名字迁移机制——你不用改老算例,代码替你改写并提醒。
4.5 add…ConstructorToTable:自注册的魔法
declareRunTimeSelectionTable(第 173–229 行)在展开公共基础设施后,还定义了一个模板助手类:
/* Helper to add constructor from argList to table */
template<class baseType##Type>
struct add##argNames##ConstructorToTable
{
static ptrWrapper<baseType> New argList
{
return ptrWrapper<baseType>(new baseType##Type parList);
}
explicit add##argNames##ConstructorToTable
(
const ::Foam::word& k = baseType##Type::typeName // 默认键 = 派生类的 typeName
)
{
argNames##ConstructorTablePtr_construct(true);
if (!argNames##ConstructorTablePtr_->insert(k, New))
{
std::cerr
<< "Duplicate entry " << k << " in runtime table "
<< #baseType << std::endl;
::Foam::error::safePrintStack(std::cerr);
}
}
~add##argNames##ConstructorToTable()
{
argNames##ConstructorTablePtr_construct(false);
}
// 禁止拷贝/赋值
};
这个模板类只有一件工作:在它的构造函数里,把"构造器 New"以键 k 插入全局表。
New是一个静态函数:new 派生类(...)后用tmp/autoPtr包起来。也就是说,表里存的"工厂",本质就是new。- 默认键
k = 派生类::typeName——这正是TypeName("kEpsilon")宏提供的静态字符串。 - 构造时
prefix##TablePtr_construct(true)确保表已建好;插入失败(重名)会打印"Duplicate entry"并栈回溯。 - 析构时
prefix##TablePtr_construct(false)递减/销毁表。
4.5.1 注册的落地:一个文件作用域的静态对象
addToRunTimeSelectionTable.H(第 42–47 行)把上面这个模板类实例化成一个文件作用域的静态对象:
#define addToRunTimeSelectionTable\
(baseType,thisType,argNames) \
\
/* Add thisType factory method to the table */ \
baseType::add##argNames##ConstructorToTable<thisType> \
add##thisType##argNames##ConstructorTo##baseType##Table_
展开后就是:
// 在派生类的 .C 文件里,namespace Foam 内(或全局作用域)
fvPatchField<Type>::adddictionaryConstructorToTable<myFvPatchField<Type>>
addmyFvPatchFielddictionaryConstructorTofvPatchFieldTable_;
这个对象在程序加载(静态初始化)时自动运行构造函数,从而完成注册。这就是"静态自注册"(self-registration)惯用法。没有任何显式的 registerAll() 调用;只要你链接了包含该 .C 的库,对应的模型就自动可用。
4.6 等价变体:命名注册、别名、模板
4.6.1 用自定义名字注册
addNamedToRunTimeSelectionTable(第 60–66 行)允许用 lookupName 而非 typeName 作键:
#define addNamedToRunTimeSelectionTable\
(baseType,thisType,argNames,lookupName) \
baseType::add##argNames##ConstructorToTable<thisType> \
add##thisType##argNames##ConstructorTo##\
baseType##Table_##lookupName##_(#lookupName)
这样一个类可以在多个名字下被查找(例如 RNGkEpsilon 与 kEpsilonRNG)。
4.6.2 别名与弃用迁移
addAliasToRunTimeSelectionTable(第 80–86 行)与配套的 addAlias##argNames##ConstructorToTable(第 181–193 行):
struct addAlias##argNames##ConstructorToTable \
{ \
explicit addAlias##argNames##ConstructorToTable \
(const ::Foam::word& k, const ::Foam::word& alias, const int ver) \
{ \
argNames##ConstructorCompatTable() \
.set(alias, std::pair<::Foam::word,int>(k,ver)); \
} \
};
它不注册构造器,只在"兼容表"里把 alias 映射到 (k, ver)。配合 4.4.2 的查找逻辑,老名字 alias 会被自动解析为新名字 k,并按 ver 给出弃用告警。这就是 OpenFOAM 在版本迭代中让旧算例继续运行的"软着陆"机制。
4.6.3 "New 表"变体
declareRunTimeNewSelectionTable(第 271–364 行)用于"派生类本身也是一个工厂"的场景——表里存的不再是 new,而是调用派生类的 New(...):
static ptrWrapper<baseType> New##baseType argList
{
return ptrWrapper<baseType>(baseType##Type::New parList.ptr());
}
4.6.4 模板基类的三套宏
C++ 里模板基类无法用普通 declare/define 宏(虚成员与静态成员的定义语法不同),因此专门有:
defineTemplateRunTimeSelectionTable(baseType, argNames):当baseType是 typedef(不需模板参数)时用;defineTemplatedRunTimeSelectionTable(baseType, argNames, Targ):当baseType需要模板参数时用;addTemplateToRunTimeSelectionTable/addTemplatedToRunTimeSelectionTable:对应的注册宏。
4.7 三个真实案例
4.7.1 边界条件:fvPatchField 的三张表
src/finiteVolume/fields/fvPatchFields/fvPatchField/fvPatchField.H(第 352–389 行)声明了三张选择表:
declareRunTimeSelectionTable
(
tmp, fvPatchField, patch,
(const fvPatch& p, const DimensionedField<Type, volMesh>& iF),
(p, iF)
);
declareRunTimeSelectionTable
(
tmp, fvPatchField, patchMapper,
(const fvPatchField<Type>& ptf, const fvPatch& p,
const DimensionedField<Type, volMesh>& iF, const fvPatchFieldMapper& m),
(dynamic_cast<const fvPatchFieldType&>(ptf), p, iF, m)
);
declareRunTimeSelectionTable
(
tmp, fvPatchField, dictionary,
(const fvPatch& p, const DimensionedField<Type, volMesh>& iF,
const dictionary& dict),
(p, iF, dict)
);
为什么是三张?因为边界条件有三套构造来源:
| 表名 | 构造来源 | 用途 |
|---|---|---|
patch | 从 patch + 内部场 | 程序内部生成(如 zeroGradient 默认) |
patchMapper | 从另一个 patchField + mapper | 网格映射/拓扑变化时重建 |
dictionary | 从 patch + 内部场 + 字典 | 从 0/p 的 type ...; 读入 |
每个具体边界条件用 makePatchTypeField 宏把三种构造一次性注册。以 fixedFluxPressureFvPatchScalarField 为例(.cxx 第 166–173 行):
namespace Foam
{
makePatchTypeField
(
fvPatchScalarField,
fixedFluxPressureFvPatchScalarField
);
}
makePatchTypeField 内部展开成三条 addToRunTimeSelectionTable(分别对应 patch/patchMapper/dictionary 表),加上 TypeName 与 typedefs。于是只要用户写 type fixedFluxPressure;,fvPatchField::New 便能按 dictionary 表构造它。
4.7.2 湍流模型:模板 RTS 与"多态家族"
湍流模型的 RTS 由 makeTurbulenceModel.H 的宏家族生成。makeBaseTurbulenceModel(第 65–117 行)一次性为三种模型族建立表与注册:
namespace Foam
{
/* Turbulence selection table */
defineTemplateRunTimeSelectionTable(Transport##baseModel, dictionary);
/* Laminar models */
defineNamedTemplateTypeNameAndDebug(laminar##Transport##BaseModel, 0);
defineTemplateRunTimeSelectionTable(laminar##Transport##BaseModel, dictionary);
addToRunTimeSelectionTable(Transport##baseModel, laminar##Transport##BaseModel, dictionary);
/* RAS models */
...
/* LES model types */
...
}
而单个模型(如 kEpsilon)用 makeTurbulenceModel(第 162–176 行)注册:
#define makeTurbulenceModel(BaseModel, SType, Type) \
namespace Foam \
{ \
namespace SType##Models \
{ \
defineTypeNameAndDebug(Type, 0); \
addToRunTimeSelectionTable(SType##BaseModel, Type, dictionary); \
} \
}
这就是为什么 turbulentFluidThermoModels.C(第 74–116 行)里能一行一个地把 kEpsilon、kOmega、kOmegaSST、SpalartAllmaras、realizableKE、GEKO……全部登记进 RASModel 表:
makeRASModel(SpalartAllmaras);
makeRASModel(kEpsilon);
makeRASModel(RNGkEpsilon);
makeRASModel(realizableKE);
makeRASModel(kOmega);
makeRASModel(kOmegaSST);
...
kEpsilon.H 第 165 行的 TypeName("kEpsilon") 提供了键名,new 出的对象类型则是模板实例族(如 kEpsilon<EddyDiffusivity<fluidThermoModel>>)。
4.7.3 线性求解器:对称/非对称两张表
LduMatrix.H(第 184–228 行)为求解器声明了 symMatrix 与 asymMatrix 两张表——因为对称矩阵(如 laplacian)可用 CG,非对称(如 div)必须用 BiCGStab:
declareRunTimeSelectionTable(autoPtr, solver, symMatrix,
(const word& fieldName, const lduMatrix& matrix,
const FieldField<Field, scalar>& interfaceBouCoeffs,
const FieldField<Field, scalar>& interfaceIntCoeffs,
const lduInterfaceFieldPtrsList& interfaces,
const dictionary& solverControls),
(fieldName, matrix, interfaceBouCoeffs, interfaceIntCoeffs,
interfaces, solverControls));
declareRunTimeSelectionTable(autoPtr, solver, asymMatrix, ...);
于是 fvSolution 里写 solver PCG; / solver PBiCGStab; / solver GAMG;,就会分别落到对应的表去构造。
4.8 为什么这套机制如此重要
把 RTS 放回整个框架看,它带来了四个决定性的能力:
- 可插拔(Pluggable):添加一个湍流模型/边界条件/格式,只需新建
.H/.C并addToRunTimeSelectionTable,不必改动任何基类或工厂代码。 - 跨库组装:模型可以分散在不同
.so中,靠链接与静态初始化自动汇入同一张表。Time里甚至有"动态库表",支持运行期dlopen额外库(controlDict的libs条目)。 - 字典驱动、用户友好:用户只写字符串名,程序自己找到实现,并负责弃用告警。
- 统一模式:从边界条件到湍流模型到线性求解器到函数对象,全都遵循同一套
declare/define/add心智模型。学会一次,处处适用。
4.9 使用 RTS 的注意事项(工程经验)
- 键名必须唯一:重名会在
insert时打印Duplicate entry并栈回溯。多库同时定义同名模型是常见坑。 - 静态初始化顺序:正是为此,OpenFOAM 才用
constructed+ 引用计数式的析构(4.4.1)。自定义库时不要绕过这套机制自己管理全局表。 - 模板类型名:模板模型的键名靠
defineNamedTemplateTypeNameAndDebug生成,注意TypeName与New的对齐。 - 别名迁移:升级版本时给旧名加
addAliasToRunTimeSelectionTable,可以让老算例无痛过渡。 - 调试:
FOAM_DEBUG/DebugInfo与error::warnAboutAge是排查"为什么没找到某个模型"的入口。
本章要点回顾
- RTS = 抽象工厂 + 字符串注册表 + 静态自注册,由
declare/define/add三宏实现。 declareRunTimeSelectionTableBase展开出<prefix>Ptr(函数指针)、<prefix>TableType(HashTable<Ptr,word>)、单例指针与查找函数。defineRunTimeSelectionTableBase提供存储与查找实现,用constructed布尔解决静态初始化顺序问题,用兼容表实现旧名迁移。addToRunTimeSelectionTable在派生类源文件生成一个文件作用域静态对象,其构造即注册。- 变体覆盖:命名注册(
addNamed...)、别名迁移(addAlias...)、"New 表"、模板基类三套宏。 - 真实案例:
fvPatchField三张表、湍流模型模板 RTS 家族、lduMatrix::solver的 sym/asym 双表。