Skip to content

Actions can not use concepts / constrained auto parameters #629

Closed

Description

@gotnone

Expected Behavior

I expected to be able to write actions that can take constrained auto parameters

Actual Behavior

State machine fails to compile

Steps to Reproduce the Problem

  1. Create a concept
  2. Add the concept as a constrained auto parameter to an action lambda
  3. Create an instance of a type satisfying the concept
  4. pass the instance of the type to the sm constructor
  5. Attempt to compile
  6. See template errors and failure to compile

See the "hello world" example modified to use a concept in the send_fin action:
https://godbolt.org/z/ErxT5YqM9

A version that tries to call a wrapped free function with a constrained auto type:
https://godbolt.org/z/GThY848jo

Specifications

  • Version: 0bdc4f5
  • Platform: Mingw64 and Linux
  • Subsystem:

Activity

  1. Schoppenglas commented on Jan 13, 2025

    @Schoppenglas

    The SML lib targets C++14.

  2. PavelGuzenfeld commented on May 29, 2026

    @PavelGuzenfeld
    Contributor

    Root cause analysis (verified by compilation)

    args_t<F, E> (sml.hpp ~2448) probes callable argument types in two steps:

    // 1. instantiate template op() with the event type
    auto args_(int) -> function_traits_t<decltype(&T::operator()<get_event_t<E>>)>;
    // 2. take address of non-template op()
    auto args1_(int) -> function_traits_t<decltype(&T::operator())>;
    // fallback: action_base
    aux::type_list<action_base> args1_(...);

    For [](Foolike auto f){}: step 1 SFINAE-fails (constraint rejects EventType), step 2 SFINAE-fails (template operator() → can't take address without explicit args), falls to action_base. Then call<TEvent, type_list<action_base>>::execute (sml.hpp:2587) fires:

    return object(event, sm, deps, subs);  // 4-arg internal context passthrough

    Compile error: no match for call to (lambda(auto:5)) (const release&, sm_impl&, pool<>&, pool<sm_impl>&). The lambda expects one Foolike-constrained arg, not 4 internal SML context objects.

    Important: action_base is not a "no-arg" fallback — it is the dispatch path for SML internals (defer, process_impl) that derive from action_base and deliberately consume the full (event, sm, deps, subs) context. Any call-site fix must gate on !is_base_of<action_base, decay_t<T>> to avoid breaking those.


    The same limitation hits C++14 generic lambdas

    [](Concept auto x){} is C++20 syntax, but the analogous C++14 problem is [](auto x){} where x is meant to be a dep (not the event).

    Verified by compilation:

    struct MyDep { int value = 42; };
    const auto my_action = [](auto&& x) { /* wants MyDep */ };
    
    struct SM {
        auto operator()() const {
            return make_transition_table(*"s1"_s + event<trigger> / my_action = X);
        }
    };
    sml::sm<SM> sm{MyDep{}};  // error at process_event()

    Step 1 of the probe succeeds for [](auto&& x){} — &T::operator()<trigger> instantiates cleanly, returning type_list<trigger&&>. SML concludes the callable wants the event, not a dep. At call time it executes get<trigger&&>(pool<MyDep>) → invalid cast, compile error. Different symptom, same root cause: the probe always runs with the event type, so any template operator() that intends to accept a dep is misrouted.


    Fix paths

    Path A — explicit per-action annotation (C++14, additive, no behavior change)

    A wrapper type that exposes a concrete operator()(Deps...) so function_traits can decompose it:

    const auto send_fin = sml::action<Foo>([](Foolike auto f){});
    // or for event + dep:
    const auto send_fin2 = sml::action<const release&, Foo>([](const release& e, Foolike auto f){});

    The wrapper has a non-template operator()(Dep&... deps) that forwards to the inner lambda. No changes to the probe chain or call<> specializations. ~25 lines, zero risk to existing code.

    Con: user must annotate every constrained/generic-dep action. Boilerplate for a common pattern.

    Path B — dep probe at call site (C++14+, automatic)

    In call<TEvent, type_list<action_base>>::execute, gate on !is_base_of<action_base, decay_t<T>> and probe the dep pool types with a C++14-compatible SFINAE trick:

    // detect: is F callable with a single Dep?
    template <class F, class D>
    auto try_call(F& f, D& d, int) -> decltype(f(d), void()) { f(d); }
    template <class F, class D>
    void try_call(F& f, D& d, ...) { /* no match */ }

    Iterate over pool type list; invoke the first match. Must also handle event-as-first-arg combinations and guard (bool-return) variants.

    Con: O(N) probe instantiations per action per dep type. Ambiguity if multiple deps satisfy the callable. More complex to implement; careful handling needed for multi-arg constrained lambdas. The guard on !is_base_of<action_base> is load-bearing — omitting it silently breaks defer/process.

    Path C — sml::deps<> hint propagated to args_ (architectural)

    The SM-level sml::deps<Ts...> policy already feeds explicit dep types into the pool. If those types were threaded into the args_ probe as additional candidates to try before falling back to action_base, the constrained lambda could be matched at introspection time.

    Con: args_t<F, E> is a pure type alias from callable + event; adding dep types as a third parameter threads through the entire transition/mapping machinery. Significant refactor, high risk.


    Workaround today: use a concrete typed lambda [](Foo f){} or the existing sml::deps<Foo> SM policy with an unconstrained [](auto f){} (though the latter still hits the generic-lambda misrouting described above — concrete type is safest).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions