This is an automated email from the ASF dual-hosted git repository.

tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git


The following commit(s) were added to refs/heads/main by this push:
     new 79697974 [FEAT][FFI] Accept flexible callback return types in C++ 
StructuralMap (#725)
79697974 is described below

commit 796979744f8ec4dc7c72238f493be7acd00f89e9
Author: Tianqi Chen <[email protected]>
AuthorDate: Tue Sep 1 15:28:22 2026 -0400

    [FEAT][FFI] Accept flexible callback return types in C++ StructuralMap 
(#725)
    
    Add flexible callback-return support to C++ `StructuralMap` through two
    additive `Expected<T>` conversions.
    
    - Preserve the existing `Expected(T)`, `Expected(Error)`, and
    `Expected(Unexpected<E>)` constructors unchanged.
    - Accept a bare `U` when `U` is implicitly convertible to `T`, using the
    C++17-compatible implicit subset of `std::expected`'s value-constructor
    rules.
    - Convert `Expected<U>` to `Expected<T>`; when `T` subsumes `U`, move
    the shared `Any data_` representation directly, and otherwise convert
    success values while propagating errors.
    - Document that StructuralMap callbacks may return a replacement value,
    `Expected<T>`, `Error`/`Unexpected`, or throw `Error`, while retaining
    the diagnostic for unsupported return types.
    
    StructuralWalk and the Rust bindings are unchanged.
---
 include/tvm/ffi/expected.h                | 63 +++++++++++++++++++++++++++++++
 include/tvm/ffi/extra/structural_mutate.h | 24 ++++++++----
 tests/cpp/extra/test_structural_mutate.cc | 58 ++++++++++++++++++++++++++++
 tests/cpp/test_expected.cc                | 29 ++++++++++++++
 4 files changed, 166 insertions(+), 8 deletions(-)

diff --git a/include/tvm/ffi/expected.h b/include/tvm/ffi/expected.h
index 14412ac9..4d31fdce 100644
--- a/include/tvm/ffi/expected.h
+++ b/include/tvm/ffi/expected.h
@@ -64,10 +64,25 @@ template <typename E>
 Unexpected(E) -> Unexpected<E>;
 #endif
 
+template <typename T>
+class Expected;
+
 namespace details {
 
 struct ExpectedUnsafe;
 
+template <typename T>
+inline constexpr bool is_expected_v = false;
+
+template <typename T>
+inline constexpr bool is_expected_v<Expected<T>> = true;
+
+template <typename T>
+inline constexpr bool is_unexpected_v = false;
+
+template <typename E>
+inline constexpr bool is_unexpected_v<Unexpected<E>> = true;
+
 }  // namespace details
 
 /*!
@@ -111,6 +126,52 @@ class Expected {
   // NOLINTNEXTLINE(google-explicit-constructor,runtime/explicit)
   Expected(T value) : data_(Any(std::move(value))) {}
 
+  /*!
+   * \brief Implicit constructor from a different success value type.
+   * \tparam U Source type implicitly convertible to ``T``.
+   * \param value The success value to convert.
+   */
+  // Excludes Error, Unexpected, and Expected deliberately: Any subsumes all 
three, so without
+  // these an Expected<Any> built from an error would store it as a success 
value. The Expected
+  // exclusion also keeps this overload disjoint from Expected(Expected<U>) 
instead of relying on
+  // partial ordering to choose between two paths that must agree.
+  //
+  // std::expected admits constructible sources and uses C++20 explicit(bool) 
to separate its
+  // implicit subset. Under C++17, convertibility keeps exactly that implicit 
subset and drops only
+  // explicit-only conversions; is_constructible plus explicit(bool) can 
extend it after an upgrade.
+  template <typename U, typename = 
std::enable_if_t<!details::is_expected_v<std::decay_t<U>> &&
+                                                    
!details::is_unexpected_v<std::decay_t<U>> &&
+                                                    !std::is_base_of_v<Error, 
std::decay_t<U>> &&
+                                                    std::is_convertible_v<U, 
T>>>
+  // NOLINTNEXTLINE(google-explicit-constructor,runtime/explicit)
+  Expected(U&& value) : data_(Any(T(std::forward<U>(value)))) {}
+
+  /*!
+   * \brief Implicit converting constructor from another Expected success type.
+   * \tparam U Source success type whose storage is subsumed by or implicitly 
convertible to ``T``.
+   * \param other The Expected value to convert.
+   */
+  // Subsumption belongs only here: this source already contains a 
materialized U or Error whose
+  // representation may be reused. Applying type_subsumes_v<Any, U> to the 
bare-value constructor
+  // would accept every U, including types that cannot be materialized as Any, 
and fail in its body.
+  // Taking by value gives a local to move from, copying an lvalue source and 
moving an rvalue. The
+  // implicit copy constructor still wins for Expected<T> itself by the 
non-template tiebreaker.
+  template <typename U,
+            typename = std::enable_if_t<!std::is_void_v<U> &&
+                                        (type_subsumes_v<T, U> || 
std::is_convertible_v<U, T>)>>
+  // NOLINTNEXTLINE(google-explicit-constructor,runtime/explicit)
+  Expected(Expected<U> other) {
+    if constexpr (type_subsumes_v<T, U>) {
+      // data_ holds a T or an Error. Subsumption proves the source 
representation already
+      // satisfies that invariant, so the Any moves without inspecting its 
state. Do not make
+      // this unconditional: value() reads back through 
MoveFromAnyAfterCheck<T>, whose check is
+      // the success/error state, not the type.
+      data_ = std::move(other.data_);
+    } else {
+      data_ = other.is_err() ? Any(std::move(other).error()) : 
Any(T(std::move(other).value()));
+    }
+  }
+
   /*!
    * \brief Implicit constructor from an error.
    * \param error The error value.
@@ -199,6 +260,8 @@ class Expected {
   }
 
  private:
+  template <typename>
+  friend class Expected;
   Expected() = default;
 
   friend struct details::ExpectedUnsafe;
diff --git a/include/tvm/ffi/extra/structural_mutate.h 
b/include/tvm/ffi/extra/structural_mutate.h
index 908bb5e8..c7916b79 100644
--- a/include/tvm/ffi/extra/structural_mutate.h
+++ b/include/tvm/ffi/extra/structural_mutate.h
@@ -812,17 +812,20 @@ struct StructuralMapCallbackChain {
 
   /*!
    * \brief Invoke a matched callback with optional def-region context.
-   * \tparam Callback Callable whose result is convertible to 
``Expected<Any>``.
+   * \tparam Callback Callable returning a value implicitly convertible to 
``Expected<Any>``.
    * \tparam Value Type of the converted value passed to the callback.
    * \param callback The matched callback.
    * \param value The converted value passed to the callback.
    * \param kind The active def-region kind.
-   * \return The callback result converted to ``Expected<Any>``.
+   * \return The callback result normalized to ``Expected<Any>``.
    */
   template <typename Callback, typename Value>
   TVM_FFI_INLINE static Expected<Any> InvokeCallbackLink(Callback& callback, 
Value&& value,
                                                          TVMFFIDefRegionKind 
kind) {
     using FuncInfo = FunctionInfo<std::decay_t<Callback>>;
+    static_assert(std::is_convertible_v<typename FuncInfo::RetType, 
Expected<Any>>,
+                  "StructuralMap callbacks must return a replacement value, 
Error, Unexpected, "
+                  "or Expected<U> implicitly convertible to Expected<Any>");
     if constexpr (FuncInfo::num_args == 1) {
       return callback(std::forward<Value>(value));
     } else {
@@ -843,8 +846,9 @@ struct StructuralMapCallbackChain {
  * argument is borrowed and must not be retained after the callback returns.
  *
  * Each callback should follow map semantics: it must not mutate the input in 
place and should
- * return ``Expected<Any>`` containing either the unchanged input or its 
replacement. An ``Error``
- * stops the mapping. In pre-order, an unchanged input or uniquely owned 
replacement may
+ * return a bare Any-convertible replacement or ``Expected<U>`` where ``U`` is 
Any-convertible.
+ * A callback may instead return an error value or throw ``Error`` to stop the 
mapping. In
+ * pre-order, an unchanged input or uniquely owned replacement may
  * continue through ``MaybeInplaceMutate``; a shared replacement uses 
``Mutate``. In post-order,
  * the callback runs after the node's optional in-place mutation. In-place 
mutation is available
  * only through an explicit ``__s_maybe_inplace_mutate__`` hook.
@@ -877,10 +881,12 @@ struct StructuralMapCallbackChain {
  * \tparam Callbacks Callback types whose first parameters select matching 
values.
  * \param root The borrowed root value to map.
  * \param callbacks Callbacks tested in declaration order. Each accepts 
``(value)`` or
- *        ``(value, def_region_kind)`` and should return ``Expected<Any>``.
+ *        ``(value, def_region_kind)`` and returns a bare Any-convertible 
replacement,
+ *        ``Expected<U>`` where ``U`` is Any-convertible, or an error value.
  * \return The mapped owning value, or an Error if mapping or a callback fails.
  *
- * \note Return type of each callback should be ``Expected<Any>``.
+ * \note Returning ``Expected<U>`` expresses errors as values; throwing 
``Error`` is also
+ *       supported and is converted to the error state.
  */
 template <WalkOrder order, typename... Callbacks>
 Expected<Any> StructuralMapExpected(AnyView root, Callbacks&&... callbacks) 
noexcept {
@@ -902,11 +908,13 @@ Expected<Any> StructuralMapExpected(AnyView root, 
Callbacks&&... callbacks) noex
  * \tparam Callbacks Callback types whose first parameters select matching 
values.
  * \param root The borrowed root value to map.
  * \param callbacks Callbacks tested in declaration order. Each accepts 
``(value)`` or
- *        ``(value, def_region_kind)`` and should return ``Expected<Any>``.
+ *        ``(value, def_region_kind)`` and returns a bare Any-convertible 
replacement,
+ *        ``Expected<U>`` where ``U`` is Any-convertible, or an error value.
  * \return The mapped owning value.
  * \throws Error if mapping or a callback fails.
  *
- * \note Return type of each callback should be ``Expected<Any>``.
+ * \note Returning ``Expected<U>`` expresses errors as values; throwing 
``Error`` is also
+ *       supported and is rethrown by this interface.
  */
 template <WalkOrder order, typename... Callbacks>
 Any StructuralMap(AnyView root, Callbacks&&... callbacks) {
diff --git a/tests/cpp/extra/test_structural_mutate.cc 
b/tests/cpp/extra/test_structural_mutate.cc
index 2066d074..d2feb251 100644
--- a/tests/cpp/extra/test_structural_mutate.cc
+++ b/tests/cpp/extra/test_structural_mutate.cc
@@ -24,6 +24,7 @@
 
 #include <cstdint>
 #include <string>
+#include <type_traits>
 #include <utility>
 #include <vector>
 
@@ -157,6 +158,63 @@ TEST(StructuralMap, PreOrderRecursivelyMapsCallbackResult) 
{
   EXPECT_EQ(mapped_value[0].cast<int64_t>(), 11);
 }
 
+TEST(StructuralMap, AcceptsExpectedCallbackReturnTypes) {
+  static_assert(std::is_convertible_v<Expected<TVar>, Expected<Any>>,
+                "Expected<T> should implicitly convert to Expected<Any>");
+  static_assert(std::is_convertible_v<TVar, Expected<Any>>,
+                "Bare values convertible to Any should convert to 
Expected<Any>");
+  TVar root("n");
+
+  TVar bare = StructuralMap<WalkOrder::kPostOrder>(root, [](const TVar&) -> 
TVar {
+                return TVar("bare");
+              }).cast<TVar>();
+  EXPECT_EQ(bare->name, "bare");
+
+  // Any already converted directly to Expected<Any> before Expected<U> 
support was added.
+  TVar any = StructuralMap<WalkOrder::kPostOrder>(root, [](const TVar&) -> Any 
{
+               return Any(TVar("any"));
+             }).cast<TVar>();
+  EXPECT_EQ(any->name, "any");
+
+  TVar expected_object =
+      StructuralMap<WalkOrder::kPostOrder>(root, [](const TVar&) -> 
Expected<TVar> {
+        return TVar("expected-object");
+      }).cast<TVar>();
+  EXPECT_EQ(expected_object->name, "expected-object");
+
+  TVar expected_any = StructuralMap<WalkOrder::kPostOrder>(root, [](const 
TVar&) -> Expected<Any> {
+                        return Any(TVar("expected-any"));
+                      }).cast<TVar>();
+  EXPECT_EQ(expected_any->name, "expected-any");
+
+  auto check_error = [](auto callback, const char* expected_message) {
+    Expected<Any> result =
+        StructuralMapExpected<WalkOrder::kPostOrder>(TVar("n"), 
std::move(callback));
+    ASSERT_TRUE(result.is_err());
+    EXPECT_EQ(result.error().kind(), "ValueError");
+    EXPECT_EQ(result.error().message(), expected_message);
+  };
+  check_error(
+      [](const TVar&) -> Expected<TVar> {
+        return Error("ValueError", "expected object error", "");
+      },
+      "expected object error");
+  check_error(
+      [](const TVar&) -> Expected<Any> { return Error("ValueError", "expected 
any error", ""); },
+      "expected any error");
+  check_error([](const TVar&) -> Any { return Any(Error("ValueError", "any 
error", "")); },
+              "any error");
+  check_error([](const TVar&) -> Error { return Error("ValueError", "direct 
error", ""); },
+              "direct error");
+  check_error(
+      [](const TVar&) -> Unexpected<Error> {
+        return Unexpected(Error("ValueError", "unexpected error", ""));
+      },
+      "unexpected error");
+  check_error([](const TVar&) -> Expected<TVar> { throw Error("ValueError", 
"thrown error", ""); },
+              "thrown error");
+}
+
 template <WalkOrder order>
 void CheckRepeatedVarRemap() {
   TVar var("n");
diff --git a/tests/cpp/test_expected.cc b/tests/cpp/test_expected.cc
index d4d2ac26..3e365359 100644
--- a/tests/cpp/test_expected.cc
+++ b/tests/cpp/test_expected.cc
@@ -95,6 +95,35 @@ TEST(Expected, ObjectRefType) {
   EXPECT_EQ(result.value()->value, 123);
 }
 
+TEST(Expected, ImplicitConvertingConstructor) {
+  static_assert(std::is_convertible_v<String, Expected<Any>>);
+  static_assert(!std::is_convertible_v<String, Expected<int>>);
+  static_assert(std::is_assignable_v<Expected<Any>&, String>);
+  static_assert(std::is_convertible_v<Expected<int>, Expected<double>>);
+  static_assert(std::is_convertible_v<Expected<String>, Expected<Any>>);
+  static_assert(!std::is_convertible_v<Expected<void>, Expected<Any>>);
+
+  // Non-subsuming success types require value conversion.
+  Expected<double> success = Expected<int>(42);
+  ASSERT_TRUE(success.is_ok());
+  EXPECT_EQ(success.value(), 42.0);
+
+  Expected<double> failure = Expected<int>(Error("ValueError", "conversion 
failed", ""));
+  ASSERT_TRUE(failure.is_err());
+  EXPECT_EQ(failure.error().kind(), "ValueError");
+  EXPECT_EQ(failure.error().message(), "conversion failed");
+
+  // Any subsumes String storage, so both success and error states move 
without inspection.
+  Expected<Any> subsumed_success = Expected<String>(String("hello"));
+  ASSERT_TRUE(subsumed_success.is_ok());
+  EXPECT_EQ(subsumed_success.value().cast<String>(), "hello");
+
+  Expected<Any> subsumed_failure = Expected<String>(Error("TypeError", 
"subsumed error", ""));
+  ASSERT_TRUE(subsumed_failure.is_err());
+  EXPECT_EQ(subsumed_failure.error().kind(), "TypeError");
+  EXPECT_EQ(subsumed_failure.error().message(), "subsumed error");
+}
+
 // Test with String type
 TEST(Expected, StringType) {
   Expected<String> result = String("hello");

Reply via email to