2025, Nov 19 17:00

Clean pybind11 type checks: use Python builtins to detect complex numbers and callable objects in C++

Learn how to check dynamic types in pybind11 without eval: detect complex numbers and callable objects in C++ using builtins isinstance, callable, complex.

Bridging C++ and Python with pybind11 sometimes runs into a deceptively simple task: inspecting the dynamic type of a py::object. Basic cases like int, float, str, list are straightforward, but complex numbers and function handles tend to trigger awkward workarounds. The good news is that there’s a direct, clean way to do this without resorting to eval or auxiliary buffers.

Problem overview

Suppose you need to translate py::object (coming from Python) into a native C++ type that mirrors Python’s behavior. For complex numbers and function handles, the quick-and-dirty path is to bounce through Python code snippets using eval. Below is a minimal example of such a pattern.

bool isMaybeComplex(py::object any)
{
    int idx = stash_src(any); // stash in a shared buffer visible to both sides

    std::string test_expr;
    test_expr = "isinstance(bridge.pull_src("; // bridge.pull_src(idx) fetches back the object
    test_expr += std::to_string(idx);
    test_expr += "), complex)";

    std::string eval_expr;
    eval_expr = "eval('";
    eval_expr += test_expr;
    eval_expr += "')";

    py::object py_builtins = py::module_::import("builtins");
    py::object fn_eval = py_builtins.attr("eval");
    py::object verdict = fn_eval(eval_expr);

    return py::isinstance<py::bool_>(verdict) && py::cast<py::bool_>(verdict);
}

And the same idea appears when checking if the object is callable:

test_expr = "callable(bridge.pull_src(";
test_expr += std::to_string(idx);
test_expr += "))";

What’s really going on

The approach above round-trips through Python with a dynamically built expression and evaluates it at runtime. It works, but it’s fragile, slow, and hard to reason about. The core need is to ask Python: “is this object an instance of complex?” and “is this object callable?” These checks already exist in Python’s builtins. You don’t need eval; you just need to fetch and call the right builtins directly from C++ through pybind11.

Clean solution: call builtins directly

Python exposes isinstance, callable, and the complex type in the builtins module. Import it once per call, pull the attributes you need, and invoke them on the object you’re inspecting.

bool isComplexObj(py::object obj)
{
    py::object py_builtins = py::module_::import("builtins");
    py::object fn_isinstance = py_builtins.attr("isinstance");
    py::object cls_complex = py_builtins.attr("complex");

    // Let type errors propagate if something unexpected happens
    return py::cast<py::bool_>(fn_isinstance(obj, cls_complex));
}

bool isCallableObj(py::object obj)
{
    py::object py_builtins = py::module_::import("builtins");
    py::object fn_callable = py_builtins.attr("callable");
    return py::cast<py::bool_>(fn_callable(obj));
}

In Python, this behaves exactly as you’d expect:

import pybind_sample

z = 5 + 4j
print(pybind_sample.isComplexObj(z))  # prints True

You can store these objects somewhere to avoid importing them every time, unless you are planning on using multiple interpreters or restarting the interpreter.

Why this matters

Eliminating eval removes overhead and reduces the attack surface of your bindings. Your code becomes easier to read, easier to debug, and less brittle. More importantly, you rely on Python’s canonical mechanisms for type checks and callability instead of string-assembled expressions and shared buffers.

Conclusion

If you need to detect complex numbers or function handles in pybind11, don’t build eval strings. Import builtins, grab isinstance, complex, and callable, and call them directly. This keeps your bindings clean and predictable, and you can optionally cache those objects for repeated calls, with the caveat about multiple interpreters or interpreter restarts.