Type Erasure Part Four: ROS 2 Message Type System
Previously:
Type Erasure Part Two: How std::function Works
Type Erasure Part Three: The Downside
Now:
- Introduction
- Build pipeline: from
.msgto generated tree - The core erased handle:
rosidl_message_type_support_t - Dual-track architecture
- Summary: type erasure in ROS 2
Introduction
In Part I I described type erasure as hiding concrete type information behind a uniform interface, with runtime dispatch redirecting through function pointers of the same signature. ROS 2 message generation is one of the largest real-world deployments of that pattern across three languages.
Start with a single .msg file:
# demo_pkg/msg/DemoStatus.msg
std_msgs/Header header
string name
int32 code
bool active
Run colcon build on a package that declares it via rosidl_generate_interfaces(), and the build tree grows into language bindings, serialization code, introspection metadata, dispatchers, and shared libraries. At runtime the middleware does not operate on demo_pkg::msg::DemoStatus directly in most layers. It operates on void* message pointers, string identifiers, and rosidl_message_type_support_t handles — the ROS equivalent of qsort’s type-erased compare function.
This article walks through the generated code for DemoStatus from a local demo_pkg workspace (ROS 2 Jazzy, C/C++/Python only; Rust bindings are disabled in that package and are not covered here). The goal is to map folder layout, shared library roles, and where type erasure happens at compile time versus where dispatch happens at runtime.
| Type erasure concept (Parts I–III) | ROS 2 analogue |
|---|---|
| Type-erased interface | rosidl_message_type_support_t |
| Common algorithm over erased types | RMW publish/subscribe, ros2 topic echo, Python bindings |
| Per-type implementation | Per-message generated structs/classes + callback tables |
| Runtime dispatch | func resolver + dlopen/dlsym in dispatcher |
| Manual create/copy when type is erased | C DemoStatus__create / introspection init-fini |
| Binding fixed at compile time | Template specialization + macro-expanded extern "C" symbols |
Build pipeline: from .msg to generated tree
colcon build invokes CMake, which calls rosidl_generate_interfaces(). That macro drives every generator registered in the ROS installation.
DemoStatus.msg
→ rosidl_adapter → DemoStatus.idl
→ rosidl_generate_interfaces
├→ rosidl_generator_c / _cpp / _py
├→ rosidl_typesupport_fastrtps_* / introspection_*
└→ rosidl_typesupport_c / _cpp
Each generator writes into its own subfolder under build/demo_pkg/. colcon then links the outputs into package-level shared libraries under install/demo_pkg/lib/. The mapping from build folders, .so files, and what each layer does is summarized in Package libraries vs message symbols below.
Package libraries vs message symbols
Do not confuse shared libraries (one per package per layer) with exported symbols (many per message inside each library).
ROS 2 groups generated code by package and layer, not by individual message:
one package × one layer variant → one .so file
one .so file × many messages in that package → many extern "C" symbols
Naming convention (from ROSIDL_TYPESUPPORT_INTERFACE__LIBRARY_NAME):
lib<package_name>__<typesupport_layer>.so
Four layers
Typesupport for a message package splits into four layers. ROS builds C and C++ track variants of the dispatcher and implementation layers (*_c / *_cpp suffixes); the role of each layer is the same — language bindings are consumers, not separate layers.
| Layer | Build folders (pattern) | Shared libraries (pattern) | Role | Key artifact per message (DemoStatus) |
|---|---|---|---|---|
| Definition | rosidl_generator_c/, _cpp/, _py/ |
lib<pkg>__rosidl_generator_c.so, _py.so (+ C++ headers) |
Concrete message layout and lifecycle (__create / __destroy); when middleware holds void*, those are the manual ops from Part III. Forward-declares dispatcher symbols in headers only — no FastDDS or introspection logic. |
demo_status__struct.h, DemoStatus__create / __destroy |
| FastDDS | rosidl_typesupport_fastrtps_c/, _cpp/ |
lib<pkg>__rosidl_typesupport_fastrtps_c.so, _cpp.so |
Wire serialization: message_type_support_callbacks_t with cdr_serialize / cdr_deserialize on void*. Generated code casts internally to demo_pkg__msg__DemoStatus *; the callback table erases the type at the interface. |
demo_status__type_support_c.cpp, __callbacks_DemoStatus |
| Introspection | rosidl_typesupport_introspection_c/, _cpp/ |
lib<pkg>__rosidl_typesupport_introspection_c.so, _cpp.so |
Static field names, types, and offsetof for tools such as ros2 topic echo. Unlike Protobuf reflection, metadata is compiled from .msg files, not parsed at runtime. |
demo_status__type_support.c, MessageMembers |
| Dispatcher | rosidl_typesupport_c/, _cpp/ |
lib<pkg>__rosidl_typesupport_c.so, _cpp.so |
Front door: no serialization. Each message handle holds a type_support_map_t — implementation identifiers, macro-stringified dlsym names, cached dlopen handles — and a func that resolves FastDDS or introspection on first use. |
demo_status__type_support.cpp, dispatch map |
For demo_pkg (DemoStatus + DemoCommand), the install tree has eight shared libraries — two per layer (C/C++ tracks for dispatcher, FastDDS, and introspection; definition adds rosidl_generator_c and rosidl_generator_py, with C++ as headers). Separate build/ sources exist per message but link into the same .so per layer.
Symbols inside each .so
Dispatcher, FastDDS, and introspection each export one extern "C" entry symbol per message inside their package library. The C track (*_c suffix) illustrates the pattern; the C++ track uses the same layout with *_cpp identifiers.
Dispatcher layer — libdemo_pkg__rosidl_typesupport_c.so:
rosidl_typesupport_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus
rosidl_typesupport_c__get_message_type_support_handle__demo_pkg__msg__DemoCommand
FastDDS layer — libdemo_pkg__rosidl_typesupport_fastrtps_c.so:
rosidl_typesupport_fastrtps_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus
rosidl_typesupport_fastrtps_c__get_message_type_support_handle__demo_pkg__msg__DemoCommand
Introspection layer — libdemo_pkg__rosidl_typesupport_introspection_c.so (resolved when the dispatcher map matches "rosidl_typesupport_introspection_c"):
rosidl_typesupport_introspection_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus
rosidl_typesupport_introspection_c__get_message_type_support_handle__demo_pkg__msg__DemoCommand
The definition layer exposes lifecycle helpers such as demo_pkg__msg__DemoStatus__create; the FastDDS layer exposes cdr_serialize_demo_pkg__msg__DemoStatus. Only dispatcher, FastDDS, and introspection use the get_message_type_support_handle entry pattern shown above.
| Granularity | What you get | Count for demo_pkg (2 msgs) |
|---|---|---|
| Layer variant library | One .so per package per layer variant |
8 shared libraries (4 layers × C/C++ tracks, plus definition generators) |
| Message symbol | One extern "C" entry per message per typesupport layer |
2 symbols per dispatcher / FastDDS / introspection library |
| Static data per message | Dispatch map, callbacks, struct layout | Separate sources in build/, merged at link time |
When the dispatcher’s func runs, it dlopens a package-level implementation library (libdemo_pkg__rosidl_typesupport_fastrtps_c.so), then dlsyms a message-specific symbol string from that message’s dispatch map. Adding a new .msg file adds symbols to existing .so files; it does not create new libraries per message.
All message code is statically generated before the node runs. ROS “dynamic typing” means introspection plus runtime library loading for known types — not arbitrary schemas at runtime (see rosidl_dynamic_typesupport for that direction).
The core erased handle: rosidl_message_type_support_t
The definition layer does not use this struct — it exposes concrete message layouts and __create / __destroy directly. Dispatcher, FastDDS, and introspection each publish a const rosidl_message_type_support_t *: one polymorphic handle type that lets generic middleware code work with any message without knowing its C++ class or struct layout at the call site.
// rosidl_runtime_c/message_type_support_struct.h
typedef const rosidl_message_type_support_t * (* rosidl_message_typesupport_handle_function)(
const rosidl_message_type_support_t *, const char *);
struct rosidl_message_type_support_t
{
const char * typesupport_identifier;
const void * data;
rosidl_message_typesupport_handle_function func;
// Jazzy also carries type hash / description hooks; omitted here for clarity
};
Three fields, three roles:
typesupport_identifier— a string tag saying what kind of handle this is (dispatcher, FastDDS, introspection, …).data— polymorphic payload; the struct type of the pointee depends ontypesupport_identifier.func— resolver function; given a handle and a requested identifier, returns the handle that actually implements that identifier (often a different handle).
Polymorphic data
typesupport_identifier |
data points to |
Purpose |
|---|---|---|
rosidl_typesupport_c / _cpp |
type_support_map_t |
Dispatch map: identifiers, dlsym symbol names, cached library handles |
rosidl_typesupport_fastrtps_c / _cpp |
message_type_support_callbacks_t |
CDR serialize/deserialize on void* |
rosidl_typesupport_introspection_c / _cpp |
MessageMembers / introspection tables |
Field names, types, offsets |
Callers must inspect the identifier before casting:
const rosidl_message_type_support_t * handle = /* ... */;
if (0 == strcmp(handle->typesupport_identifier, "rosidl_typesupport_fastrtps_c")) {
auto * callbacks = static_cast<const message_type_support_callbacks_t *>(handle->data);
callbacks->cdr_serialize(untyped_ros_message, cdr);
} else if (0 == strcmp(handle->typesupport_identifier, "rosidl_typesupport_c")) {
auto * map = static_cast<const type_support_map_t *>(handle->data);
// map holds symbol names for dlopen/dlsym — not serialization callbacks
}
Polymorphic func
When the requested identifier matches the handle’s own identifier, func returns the same handle. When a dispatcher receives a request for "rosidl_typesupport_fastrtps_c", it searches its map, dlopens the implementation library, dlsyms the macro-generated extern "C" symbol, and returns the FastDDS handle — a different struct whose data field actually holds serialization callbacks.
A dispatcher handle cannot serialize directly: its data is a map, not message_type_support_callbacks_t. You must call func first — see How the dispatcher works (Steps 0–3).
How the dispatcher works
The dispatcher layer is the same mechanism on both tracks: each message gets a static dispatch map, a dispatcher handle pointing at that map, and a package entry symbol that returns the handle. Nothing in the map serializes messages — it only records which implementation layers exist, the dlsym strings to find them, and slots to cache loaded libraries. The shared func resolver in librosidl_typesupport_c.so / librosidl_typesupport_cpp.so does the rest.
The walkthrough below uses DemoStatus on the C track (rosidl_typesupport_c, fastrtps_c, introspection_c). The C++ track substitutes _cpp identifiers and libdemo_pkg__rosidl_typesupport_cpp.so; the control flow is identical.
Step 0: load the package dispatcher first
Resolving FastDDS or introspection is Stage 1. Before that, the process must already contain the package dispatcher library — Stage 0:
| Stage | Library loaded | Mechanism | Who triggers it |
|---|---|---|---|
| 0 — package dispatcher | libdemo_pkg__rosidl_typesupport_c.so or _cpp.so |
ELF dynamic linker at process/import time | Node binary, Python extension, or explicit dlopen |
| 1 — implementation | libdemo_pkg__rosidl_typesupport_fastrtps_*.so, _introspection_*.so |
dlopen inside the runtime func resolver |
Dispatch map on first use of that implementation |
The dispatch map dlopens only the FastDDS and introspection package libraries listed in its rows — for demo_pkg on the C track, libdemo_pkg__rosidl_typesupport_fastrtps_c.so and libdemo_pkg__rosidl_typesupport_introspection_c.so (C++ track: _fastrtps_cpp.so and _introspection_cpp.so). It does not load the dispatcher library (libdemo_pkg__rosidl_typesupport_c.so / _cpp.so); that must already be mapped through build-time linkage, Python import of the typesupport extension, or an explicit dlopen/dlsym of the dispatcher entry symbol.
| Caller | How Stage 0 happens |
|---|---|
rclcpp node |
CMake links libdemo_pkg__rosidl_typesupport_cpp.so; ld.so loads it at startup; template or macro returns the static dispatcher handle. |
rclpy / C API |
Import or link pulls in libdemo_pkg__rosidl_typesupport_c.so; entry symbol or PyCapsule resolves to the same extern "C" function. |
| Unlinked tool | Must dlopen the dispatcher .so and dlsym the package entry symbol before calling func. |
Confusing Stage 0 with Stage 1 is a common mistake: the first handle comes from linkage or import, not from the map’s lazy loader.
Step 1: generated dispatch map and entry symbol
Each message’s dispatcher code is generated into build/demo_pkg/rosidl_typesupport_<track>/.../demo_status__type_support.cpp. For DemoStatus on the C track:
static const _DemoStatus_type_support_ids_t _DemoStatus_message_typesupport_ids = {
{
"rosidl_typesupport_fastrtps_c",
"rosidl_typesupport_introspection_c",
}
};
static const _DemoStatus_type_support_symbol_names_t _DemoStatus_message_typesupport_symbol_names = {
{
STRINGIFY(ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_fastrtps_c, demo_pkg, msg, DemoStatus)),
STRINGIFY(ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_introspection_c, demo_pkg, msg, DemoStatus)),
}
};
static _DemoStatus_type_support_data_t _DemoStatus_message_typesupport_data = {
{ 0, 0 } // dlopen handles cached here after first use (Stage 1)
};
static const type_support_map_t _DemoStatus_message_typesupport_map = {
2,
"demo_pkg",
&_DemoStatus_message_typesupport_ids.typesupport_identifier[0],
&_DemoStatus_message_typesupport_symbol_names.symbol_name[0],
&_DemoStatus_message_typesupport_data.data[0],
};
static const rosidl_message_type_support_t DemoStatus_message_type_support_handle = {
rosidl_typesupport_c__typesupport_identifier,
reinterpret_cast<const type_support_map_t *>(&_DemoStatus_message_typesupport_map),
rosidl_typesupport_c__get_message_typesupport_handle_function,
};
A macro-generated extern "C" entry symbol per message is the stable front door — predictable across compilers, usable from dlsym:
extern "C"
const rosidl_message_type_support_t *
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_c, demo_pkg, msg, DemoStatus)()
{
return &::demo_pkg::msg::rosidl_typesupport_c::DemoStatus_message_type_support_handle;
}
// ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(rosidl_typesupport_c, demo_pkg, msg, DemoStatus)
// → rosidl_typesupport_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus
Calling the entry symbol does not load FastDDS yet. It returns the static dispatcher handle where:
typesupport_identifiernames the dispatcher layer (e.g."rosidl_typesupport_c")data→ the dispatch map (not serialization callbacks)func→ the runtime resolver (rosidl_typesupport_c__get_message_typesupport_handle_function)
To publish or serialize, the caller must invoke handle->func(handle, "<implementation_identifier>"), for example "rosidl_typesupport_fastrtps_c".
The map rows for DemoStatus were fixed at compile time — the resolver never guesses symbol names:
| Map index | typesupport_identifier[i] |
symbol_name[i] (via STRINGIFY) |
|---|---|---|
| 0 | rosidl_typesupport_fastrtps_c |
rosidl_typesupport_fastrtps_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus |
| 1 | rosidl_typesupport_introspection_c |
rosidl_typesupport_introspection_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus |
Step 2: func resolves the requested implementation
The resolver body lives in the ROS 2 runtime library (rosidl_typesupport_c/src/type_support_dispatch.hpp). Pseudocode matching the real control flow:
const rosidl_message_type_support_t *
rosidl_typesupport_c__get_message_typesupport_handle_function(
const rosidl_message_type_support_t * handle,
const char * identifier)
{
// Case A: caller already holds the implementation handle
if (0 == strcmp(handle->typesupport_identifier, identifier)) {
return handle;
}
// Case B: caller holds the dispatcher handle
if (0 == strcmp(handle->typesupport_identifier, "rosidl_typesupport_c")) {
const type_support_map_t * map = static_cast<const type_support_map_t *>(handle->data);
for (size_t i = 0; i < map->size; ++i) {
if (0 != strcmp(map->typesupport_identifier[i], identifier)) {
continue;
}
// Stage 1: lazy-load the implementation shared library once
if (map->data[i] == nullptr) {
// library_basename = "<package>__<identifier>"
// → libdemo_pkg__rosidl_typesupport_fastrtps_c.so on Linux
map->data[i] = dlopen(...);
}
void * sym = dlsym(map->data[i], map->symbol_name[i]);
auto impl_entry = reinterpret_cast<
const rosidl_message_type_support_t * (*)()>(sym);
return impl_entry(); // FastDDS or introspection handle
}
}
return nullptr;
}
After the first successful resolution, map->data[i] caches the loaded library (SharedLibrary wrapping the dlopen handle). Later calls skip dlopen and repeat only dlsym plus the entry call — or reuse a handle cached higher in the middleware.
Step 3: implementation entry returns a different handle
The dlsym target is not cdr_serialize. It is a second extern "C" entry in the implementation library — for example libdemo_pkg__rosidl_typesupport_fastrtps_c.so:
extern "C"
const rosidl_message_type_support_t *
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_fastrtps_c, demo_pkg, msg, DemoStatus)()
{
return &_DemoStatus__type_support;
}
That handle differs from the dispatcher handle:
typesupport_identifier == "rosidl_typesupport_fastrtps_c"data→&__callbacks_DemoStatus(message_type_support_callbacks_t)func→ the FastDDS runtime resolver (returnsselfwhen identifiers match)
Only now can middleware call callbacks->cdr_serialize(untyped_ros_message, cdr).
Two distinct extern "C" entry symbols appear in every resolution:
- Dispatcher entry (
rosidl_typesupport_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus) — returns the map-backed handle from the dispatcher.so; obtained at Stage 0. - Implementation entry (
rosidl_typesupport_fastrtps_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus) — returns the callback-backed handle from the FastDDS.so; name stored in the map for Stage 1dlsym.
Both are generated by the same macro family; only the typesupport_name token changes (rosidl_typesupport_c vs rosidl_typesupport_fastrtps_c).
End-to-end call chain (first publish)
Caller
│ Stage 0: link / import / dlsym
▼
dispatcher entry symbol ──► dispatcher handle
│
│ handle->func(handle, "rosidl_typesupport_fastrtps_c")
▼
runtime func resolver (librosidl_typesupport_c.so)
│ dlopen libdemo_pkg__rosidl_typesupport_fastrtps_c.so (first use)
│ dlsym symbol from dispatch map
▼
fastrtps entry symbol ──► FastDDS handle
│
│ callbacks->cdr_serialize via handle->data
▼
__callbacks_DemoStatus
This is the runtime half of the design from Part I: compile time generates uniform entry points and callback tables; runtime dispatch selects and loads the right entry point. Dispatch redirects function pointers — it does not erase types.
Dual-track architecture
ROS 2 message type support is split into two parallel tracks. Each track has the same three layers — dispatcher, FastDDS serialization, introspection — but different client libraries use different tracks.
C track C++ track
──────── ─────────
rclpy / rcl C API rclcpp nodes
│ │
▼ ▼
rosidl_typesupport_c rosidl_typesupport_cpp
│ │ │ │
▼ ▼ ▼ ▼
fastrtps_c introspection_c fastrtps_cpp introspection_cpp
│ │ │ │
└────┬────┘ └────┬────┘
▼ ▼
Fast DDS CDR (wire format)
| Client | Track | Why |
|---|---|---|
rclcpp (C++ nodes) |
C++ track | Typed Publisher<MsgT> / Subscription<MsgT>; compiler knows message type at compile time |
rclpy (Python nodes) |
C track | Python cannot call C++ templates; bindings wrap C structs via PyCapsule |
rcl C API, generic tools |
C track | C has no templates; stable extern "C" ABI required |
Python never uses the C++ track. _demo_status_s.c converts to demo_pkg__msg__DemoStatus (a C struct) and loads demo_pkg_s__rosidl_typesupport_c.so, which in turn links libdemo_pkg__rosidl_typesupport_c.so. The flow is always rosidl_typesupport_c → fastrtps_c / introspection_c.
C++ nodes never use the C track for their own messages. rclcpp resolves handles through rosidl_typesupport_cpp → fastrtps_cpp / introspection_cpp.
Both tracks converge on the same idea: a rosidl_message_type_support_t handle from dispatcher, FastDDS, or introspection — obtained through exported symbols, with the dispatcher’s func loading implementation libraries at runtime. The difference is how the first handle is reached and whether C++ adds a second, template-based path.
How symbols are exported (both tracks)
Regardless of track, dispatcher, FastDDS, and introspection each export one stable extern "C" function per message. The generator emits these with ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME; only the typesupport_name token changes:
| Layer | Shared library | Macro typesupport_name |
Returned handle holds |
|---|---|---|---|
| C dispatcher | libdemo_pkg__rosidl_typesupport_c.so |
rosidl_typesupport_c |
Dispatch map |
| C++ dispatcher | libdemo_pkg__rosidl_typesupport_cpp.so |
rosidl_typesupport_cpp |
Dispatch map |
| FastDDS C | libdemo_pkg__rosidl_typesupport_fastrtps_c.so |
rosidl_typesupport_fastrtps_c |
CDR callbacks |
| FastDDS C++ | libdemo_pkg__rosidl_typesupport_fastrtps_cpp.so |
rosidl_typesupport_fastrtps_cpp |
CDR callbacks |
| Introspection C | libdemo_pkg__rosidl_typesupport_introspection_c.so |
rosidl_typesupport_introspection_c |
Field metadata |
| Introspection C++ | libdemo_pkg__rosidl_typesupport_introspection_cpp.so |
rosidl_typesupport_introspection_cpp |
Field metadata |
Macro expansion for DemoStatus (same pattern, different prefix per row):
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_fastrtps_c, demo_pkg, msg, DemoStatus)
// → rosidl_typesupport_fastrtps_c__get_message_type_support_handle__demo_pkg__msg__DemoStatus
This is the ROS analogue of Part I’s less(const void*, const void*): a uniform function signature (const rosidl_message_type_support_t *()), stable across compilers because of extern "C", unique per message and typesupport layer.
Implementation layer example (FastDDS C) — the macro returns a handle whose data points at serialization callbacks:
static message_type_support_callbacks_t __callbacks_DemoStatus = {
"demo_pkg::msg", "DemoStatus",
_DemoStatus__cdr_serialize, _DemoStatus__cdr_deserialize,
_DemoStatus__get_serialized_size, _DemoStatus__max_serialized_size, nullptr
};
static rosidl_message_type_support_t _DemoStatus__type_support = {
rosidl_typesupport_fastrtps_c__identifier,
&__callbacks_DemoStatus,
get_message_typesupport_handle_function,
};
extern "C"
const rosidl_message_type_support_t *
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_fastrtps_c, demo_pkg, msg, DemoStatus)()
{
return &_DemoStatus__type_support;
}
C track usage — macros are the only entry mechanism. No templates exist. A Python node or C API caller obtains the dispatcher handle like this:
const rosidl_message_type_support_t * ts =
ROSIDL_GET_MSG_TYPE_SUPPORT(demo_pkg, msg, DemoStatus);
/* expands to a direct call of the rosidl_typesupport_c macro symbol
in libdemo_pkg__rosidl_typesupport_c.so */
To serialize, the caller invokes ts->func(ts, "rosidl_typesupport_fastrtps_c"). The dispatcher dlsyms the FastDDS macro symbol in libdemo_pkg__rosidl_typesupport_fastrtps_c.so (Step 2). The C track never names C++ templates or mangled symbols.
C++ track — macro side — the C++ dispatcher exports the same kind of macro symbol. On the C++ track this macro is still required for dlsym when loading fastrtps_cpp and introspection_cpp:
extern "C"
const rosidl_message_type_support_t *
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_cpp, demo_pkg, msg, DemoStatus)()
{
return &::demo_pkg::msg::rosidl_typesupport_cpp::DemoStatus_message_type_support_handle;
}
The dispatch map in _DemoStatus_message_typesupport_symbol_names stores macro symbol strings for the implementation layers. Runtime loading always goes through these names — on both tracks.
C++ additional export: template specializations
The C++ track adds typed access through explicit template specializations. The anchor is a generic declaration in the ROS runtime — not generated per message:
// rosidl_runtime_cpp/rosidl_typesupport_cpp/message_type_support.hpp
namespace rosidl_typesupport_cpp {
template<typename T>
const rosidl_message_type_support_t * get_message_type_support_handle();
} // namespace rosidl_typesupport_cpp
This header is the shared contract on both sides:
| Who includes it | Role |
|---|---|
Callers (rclcpp, your node) |
Compile against the declaration; call get_message_type_support_handle<demo_pkg::msg::DemoStatus>() with a known type. |
Generated typesupport (build/demo_pkg/rosidl_typesupport_cpp/.../demo_status__type_support.cpp) |
Includes the same header and emits the matching template<> specialization into libdemo_pkg__rosidl_typesupport_cpp.so. |
Per-message package headers (e.g. demo_pkg/msg/detail/demo_status__type_support.hpp) also include the generic declaration and forward-declare the separate extern "C" macro entry. The generated .cpp defines both entry points; they return the same dispatcher handle pointer but are different symbols:
| Entry path | Symbol / call site | Linker name |
|---|---|---|
| Typed C++ | rosidl_typesupport_cpp::get_message_type_support_handle<demo_pkg::msg::DemoStatus>() |
C++ mangled specialization |
Macro / dlsym |
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(rosidl_typesupport_cpp, demo_pkg, msg, DemoStatus) |
rosidl_typesupport_cpp__get_message_type_support_handle__demo_pkg__msg__DemoStatus |
The generic template declaration is the key for typed C++: callers compile against one API; the linker binds each Msg type to its specialization in libdemo_pkg__rosidl_typesupport_cpp.so. That is unrelated to the stable extern "C" name used for dynamic loading — except that the macro body may call the specialization internally.
For each message the generator defines the template specialization:
#include "rosidl_typesupport_cpp/message_type_support.hpp"
template<>
const rosidl_message_type_support_t *
get_message_type_support_handle<demo_pkg::msg::DemoStatus>()
{
return &::demo_pkg::msg::rosidl_typesupport_cpp::DemoStatus_message_type_support_handle;
}
And a separate macro entry that returns the same pointer through a different symbol:
extern "C"
const rosidl_message_type_support_t *
ROSIDL_TYPESUPPORT_INTERFACE__MESSAGE_SYMBOL_NAME(
rosidl_typesupport_cpp, demo_pkg, msg, DemoStatus)()
{
return get_message_type_support_handle<demo_pkg::msg::DemoStatus>();
}
rclcpp links against the template specialization. Tools that dlsym the macro never see the mangled name — they only need the predictable extern "C" string.
FastDDS and introspection follow the same pattern with their own declaration headers — rosidl_typesupport_fastrtps_cpp/message_type_support_decl.hpp and rosidl_typesupport_introspection_cpp/message_type_support_decl.hpp — each paired with per-message specializations in the corresponding package .so:
rosidl_typesupport_cpp::get_message_type_support_handle<demo_pkg::msg::DemoStatus>();
rosidl_typesupport_fastrtps_cpp::get_message_type_support_handle<demo_pkg::msg::DemoStatus>();
rosidl_typesupport_introspection_cpp::get_message_type_support_handle<demo_pkg::msg::DemoStatus>();
Each returns that layer’s static handle when the corresponding library is linked into the process.
| Question | C track | C++ track |
|---|---|---|
| How is the dispatcher handle obtained? | Macro only (ROSIDL_GET_MSG_TYPE_SUPPORT or linked call) |
Template in typed C++ code, or macro |
| How are FastDDS / introspection handles obtained? | Macro via dispatcher func + dlsym |
Template when linked, or macro via dispatcher func + dlsym |
| Who uses templates? | Nobody | Typed C++ with link-time dependency on each layer’s library |
Who uses macros / dlsym? |
Everyone on the C track | Dispatch map resolution, dynamic tools, Python/C callers, and any cross-.so load |
When rclcpp uses templates: rclcpp::Publisher<demo_pkg::msg::DemoStatus> typically calls rosidl_typesupport_cpp::get_message_type_support_handle<...>() first — a direct linked call, type-checked, not dlsym-able. It can also call the FastDDS or introspection templates directly when those libraries are linked; in practice publish/subscribe usually goes dispatcher → func → implementation.
When macros still matter on the C++ track: the dispatch map stores macro symbol strings for FastDDS and introspection. Resolving an implementation through handle->func(...) — or loading a typesupport .so that was not linked at build time — always uses extern "C" names and dlsym, even on the C++ track.
Rule of thumb: C++ with a known message type and the layer’s library linked → template (rosidl_typesupport_cpp::, rosidl_typesupport_fastrtps_cpp::, or rosidl_typesupport_introspection_cpp::get_message_type_support_handle<Msg>()). String identifier, Python, C API, or lazy-loading an implementation through the dispatcher’s func → extern "C" macro names and dlsym.
Summary: type erasure in ROS 2
Part I separates binding (fixed at compile or construction time) from dispatch (runtime redirection through function pointers of the same signature). ROS 2 message typesupport is a large-scale instance of that pattern. It does not introduce a third mechanism — it combines the two basic ways Part I described to produce uniform entry points and per-type bodies:
| Part I actualization | ROS 2 analogue |
|---|---|
| Template — compiler emits different functions, same signature | get_message_type_support_handle<Msg>(), per-message template<> specializations; cdr_serialize(const void *, …) in generated callback tables |
| Manually written / generated non-template functions, same signature | Macro-expanded extern "C" entry symbols (rosidl_typesupport_*__get_message_type_support_handle__…); ROSIDL_GET_MSG_TYPE_SUPPORT; shared runtime resolvers (handle->func) |
Binding at compile time. rosidl_generate_interfaces() fixes the layout before the node runs. For each message and layer, generators emit either a template specialization or a macro-named extern "C" function — always returning const rosidl_message_type_support_t * or operating on void *, never exposing the concrete C++ type through the uniform interface. Per-message bodies differ; the signatures do not. After linking, typed C++ is bound to a specific specialization; macro symbol strings are baked into dispatch maps. Binding is complete before publish.
Dispatch at runtime. Middleware holds rosidl_message_type_support_t handles and void * message pointers. handle->func redirects to another function pointer of the same resolver signature; the map’s lazy dlopen/dlsym selects which macro entry to call; callbacks->cdr_serialize redirects again through a function pointer with a uniform (const void *, Cdr &) shape. That is the Part I rule: dispatch is redirection between functions that share a signature — not re-binding to a new C++ type at runtime.
What is erased. The RMW, rclpy, and generic tools do not carry demo_pkg::msg::DemoStatus as a compile-time type. They see erased handles, string identifiers, and untyped pointers, while create/destroy/serialize are supplied explicitly by generated code — as in Part III.
ROS 2 adds library-scale structure on top of Part I’s core — four layers (definition, dispatcher, FastDDS, introspection), dual C/C++ tracks, package .so files versus per-message symbols — but the underlying type-erasure engine is unchanged: template-generated and macro-generated functions with the same signature, bound at compile time, dispatched at runtime through function pointers.
For a related static-generation design, see Protobuf Reflection. JSON’s runtime model is in Type systems and JSON.