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 0aef1bf1 [FEAT][RUST] Define object types from a struct of their 
fields with `rs_class` (#869)
0aef1bf1 is described below

commit 0aef1bf18151f0d0df78f4e9331629bc0c900b2b
Author: Linzhang Li <[email protected]>
AuthorDate: Thu Oct 22 20:48:38 2026 -0400

    [FEAT][RUST] Define object types from a struct of their fields with 
`rs_class` (#869)
    
    Add `#[rs_class]` and `def_object_ref!`, which define a new FFI object
    type in
    Rust as C++ does: `#[rs_class]` goes on the object struct, whose first
    field
    `base` is the parent's object, and `def_object_ref!(TypeName,
    ParentType,
    ObjectName)` defines its reference, as C++
    `TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE` does. The type is
    registered in
    the FFI type table the way a C++ type with `TVM_FFI_DECLARE_OBJECT_INFO`
    and
    `refl::ObjectDef` is:
    
    - Its type key gets a type index under its parent's.
    - Every field is registered with its getter, setter, type schema and
    flags.
    
    C++ and Python then handle it like a C++ type:
    
    - C++ code that takes the parent type accepts it.
    - C++ structural equality and hashing, `ffi.ReprPrint` and
    `ffi.DeepCopy`
      walk its fields.
    - A Python `c_class` for its key gets the fields from the registration.
    
    ```rust
    use tvm_ffi::{def_object_ref, rs_class};
    
    #[rs_class("my_ext.PairType", final)]
    pub struct PairTypeNode {
        base: TypeObj,
        pub first: Type,
        pub second: Type,
        #[reflect(seq_hash_ignore)]
        pub note: tvm_ffi::String,
    }
    
    def_object_ref!(pub PairType, Type, PairTypeNode);
    
    impl PairType {
        pub fn new(first: impl Into<Type>, second: impl Into<Type>) -> Self {
            PairTypeNode::new(TypeObj::new(UNKNOWN_LOC), first, second, 
"").into()
        }
    }
    
    let pair = PairType::new(PrimType::int(32), PrimType::float(32));
    let ty: Type = pair.into(); // an `ir.Type` that C++ sees as 
`my_ext.PairType`
    ```
    
    On the Rust side, the two macros write what a type defined with
    `derive(Object)` / `derive(ObjectRef)` (#859) otherwise needs by hand:
    
    - `#[rs_class]`: `#[type_register]` and `#[reflect]` on each field,
    `#[repr(C)]`, `Deref` to the parent's object and `PairTypeNode::new`,
    which
      takes the parent's object, then the fields
    - `def_object_ref!`: the reference struct, `Deref` to the object,
      `From<PairTypeNode>` and the conversion into `Type`
    
    `PairTypeNode::new` and the reference's `new` that `tvm-ffi-stubgen`
    generates
    take a field the same way: a scalar (`bool`, an integer or a float) as
    its own
    type, and any other field as `impl Into<T>`. A literal infers only from
    an
    exact type: under `impl Into<u64>`, `1` falls back to `i32`, which does
    not
    convert into `u64`. The generated `new` thereby also takes a `&str` for
    a
    `String` field, a `Span` for an `Option<Span>` one, and a `PrimType` for
    a
    `Type` one, and converts each before it calls the generated
    `<Leaf>Obj::new`.
    That one takes every field as its own type, as a hand-written parent's
    `new`
    (bound with `ty-map`) does, so it hands the inherited fields on to its
    parent's as they are, and `.into()` at its call site still names a
    target. A
    subtype defined in Rust builds a generated parent's object with it.
    
    Neither macro names a type the user did not write. `ParentType` is the
    reference to the parent's object, `tvm_ffi::object::ObjectRef` for a
    type under
    `ffi.Object`, and the conversion into it is the existing
    `impl_object_upcast!`.
    A conversion into the reference to a further ancestor is another
    `impl_object_upcast!`, since Rust's `From` does not chain.
---
 docs/guides/rust_lang_guide.md                     |  46 +++
 .../rust/src/generated/rust_stubgen/mod.rs         |   4 +-
 python/tvm_ffi/stub/rust_generator/codegen.py      |  82 ++++-
 python/tvm_ffi/stub/rust_generator/consts.py       |   7 +
 rust/tvm-ffi-macros/src/def_object_ref.rs          | 134 ++++++++
 rust/tvm-ffi-macros/src/lib.rs                     |  99 ++++++
 rust/tvm-ffi-macros/src/object_macros.rs           |  67 ++--
 rust/tvm-ffi-macros/src/rs_class.rs                | 255 ++++++++++++++++
 rust/tvm-ffi/src/lib.rs                            |   2 +-
 rust/tvm-ffi/tests/test_rs_class.rs                | 338 +++++++++++++++++++++
 tests/python/test_stubgen_rust.py                  | 130 +++++++-
 11 files changed, 1116 insertions(+), 48 deletions(-)

diff --git a/docs/guides/rust_lang_guide.md b/docs/guides/rust_lang_guide.md
index 16911894..32515785 100644
--- a/docs/guides/rust_lang_guide.md
+++ b/docs/guides/rust_lang_guide.md
@@ -222,6 +222,52 @@ keeps its nearest reflected ancestor's kind. The derive 
also implements
 `ShallowCopy` and registers it as `__ffi_shallow_copy__`, which requires the
 parent to implement `ShallowCopy` and the other fields to be `Clone`.
 
+### Defining Object Types from Their Fields
+
+`#[rs_class]` defines such a type from its object struct, like Python's
+`py_class`. The first field, `base`, is the parent's object; every other field
+is reflected, and `#[reflect(...)]` sets its flags.
+`def_object_ref!(TypeName, ParentType, ObjectName)` defines the reference, as
+C++ `TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE` does:
+
+```rust
+use tvm_ffi::object::ObjectRef;
+use tvm_ffi::{def_object_ref, rs_class, Object};
+
+#[rs_class("my.Point", s_eq_hash_kind = "TreeNode")]
+pub struct PointObj {
+    base: Object,
+    pub x: i64,
+    #[reflect(writable, seq_hash_ignore)]
+    pub label: tvm_ffi::String,
+}
+
+def_object_ref!(pub Point, ObjectRef, PointObj);
+
+#[rs_class("my.Point3", final)]
+pub struct Point3Obj {
+    base: PointObj,
+    pub z: i64,
+}
+
+def_object_ref!(pub Point3, Point, Point3Obj);
+
+let point3 = Point3::from(Point3Obj::new(PointObj::new(Object::new(), 1, "p"), 
3));
+assert_eq!((point3.x, point3.z), (1, 3));
+let point: Point = point3.into();
+```
+
+`#[rs_class]` adds `#[repr(C)]`, `Deref` to the parent's object and
+`Point3Obj::new`, which takes the parent's object, then the fields: a scalar
+(`bool`, an integer or a float) as its own type, so that a literal infers as 
it,
+and any other field from anything that converts into it, as the `new` of a type
+bound by `tvm-ffi-stubgen` does. The parent's object must implement
+`ShallowCopy`, as `rs_class` types and non-final C++ types bound by
+`tvm-ffi-stubgen` do. The reference dereferences to the object, and the object
+converts into it. The reference also converts into `ParentType`, the reference
+to its parent (`ObjectRef` for a type under `ffi.Object`);
+`impl_object_upcast!` converts it into the references to further ancestors.
+
 ### Converting Borrowed Values into `Any`
 
 `Any::from(value)` takes ownership of `value`. Use it when you own the value.
diff --git a/examples/rust_stubgen/rust/src/generated/rust_stubgen/mod.rs 
b/examples/rust_stubgen/rust/src/generated/rust_stubgen/mod.rs
index de5110c2..d32c0829 100644
--- a/examples/rust_stubgen/rust/src/generated/rust_stubgen/mod.rs
+++ b/examples/rust_stubgen/rust/src/generated/rust_stubgen/mod.rs
@@ -103,8 +103,8 @@ impl IntPairObj {
 
 impl IntPair {
     /// Lossless complete-field allocation.
-    pub fn from_fields(a: i64, b: i64, kind: PairKind) -> Self {
-        let obj = IntPairObj::new(a, b, kind);
+    pub fn from_fields(a: i64, b: i64, kind: impl Into<PairKind>) -> Self {
+        let obj = IntPairObj::new(a, b, kind.into());
         Self { base: ObjectArc::new(obj) }
     }
 }
diff --git a/python/tvm_ffi/stub/rust_generator/codegen.py 
b/python/tvm_ffi/stub/rust_generator/codegen.py
index ebf568be..ec72d739 100644
--- a/python/tvm_ffi/stub/rust_generator/codegen.py
+++ b/python/tvm_ffi/stub/rust_generator/codegen.py
@@ -22,9 +22,14 @@ the verdict of :mod:`tvm_ffi.stub.layout`:
 
 - *complete*: the struct mirrors every physical field at its real offset and
   width, pinned by a ``const`` size/alignment assertion. ``<Leaf>Obj::new``
-  (crate-private) and the wrapper's ``new`` take every field root to leaf;
-  ``custom-new`` leaves the wrapper's ``new`` to hand-written code and names
-  the generated one ``from_fields``.
+  and the wrapper's ``new`` take every field root to leaf: the former each as
+  its own type, the latter a scalar as its own type and anything else as
+  ``impl Into``. ``custom-new`` leaves the wrapper's ``new`` to hand-written
+  code and names the generated one ``from_fields``. A type that is not final
+  can be the parent of a type defined in Rust (``tvm_ffi::rs_class``): its
+  ``<Leaf>Obj::new`` is public, and its object implements ``ShallowCopy`` when
+  every ancestor's is generated too (a ``ty-map``'d one is hand-written and
+  may not). ``<Leaf>Obj::new`` of a final type is crate-private.
 - *opaque*: the struct embeds only its parent, and each field is read through
   the C ABI getter. Nothing allocates it.
 
@@ -94,6 +99,16 @@ def _call_lines(open_: str, items: list[str], close: str) -> 
list[str]:
     ]
 
 
+def _param_type(rust_type: str) -> str:
+    """Type a parameter of the wrapper's allocator: a scalar as itself, 
anything else as ``impl Into``."""
+    return rust_type if rust_type in C_RUST.RUST_EXACT_PARAMS else f"impl 
Into<{rust_type}>"
+
+
+def _forward(ident: str, rust_type: str) -> str:
+    """Pass a parameter of the wrapper's allocator to ``<Leaf>Obj::new``, 
which takes ``rust_type``."""
+    return ident if rust_type in C_RUST.RUST_EXACT_PARAMS else 
f"{ident}.into()"
+
+
 def _check_width(target: str, field: NamedTypeSchema, rust_type: str, width: 
int) -> None:
     """Reject a directive whose scalar type does not match the reflected field 
size."""
     if field.size is not None and field.size != width:
@@ -482,10 +497,22 @@ class _ObjectRenderer:
         return self.imports.record(override) if "::" in override else override
 
     def _fn_lines(
-        self, head: str, params: list[tuple[str, str]], call: tuple[str, 
list[str]], init: list[str]
+        self,
+        head: str,
+        params: list[tuple[str, str]],
+        call: tuple[str, list[str]],
+        init: list[str],
+        *,
+        generic: bool = False,
     ) -> list[str]:
-        """Render ``<head>(<params>) -> Self { let <call>; Self { <init> } }`` 
in an ``impl`` block."""
-        plist = [f"{rust_ident(name)}: {rust_type}" for name, rust_type in 
params]
+        """Render ``<head>(<params>) -> Self { let <call>; Self { <init> } }`` 
in an ``impl`` block.
+
+        ``generic`` takes a parameter that is not a scalar as ``impl Into`` 
(:func:`_param_type`).
+        """
+        plist = [
+            f"{rust_ident(name)}: {_param_type(rust_type) if generic else 
rust_type}"
+            for name, rust_type in params
+        ]
         binding, args = call
         return [
             *[f"    {line}" for line in _allow_non_snake_case(rust_ident(n) 
for n, _ in params)],
@@ -498,7 +525,13 @@ class _ObjectRenderer:
     def _allocator_sections(self, base: str, has_parent: bool) -> 
list[list[str]]:
         """``<Leaf>Obj::new`` and the wrapper's ``new``.
 
+        ``<Leaf>Obj::new`` takes every field as its own type, so the parent's, 
generated
+        or hand-written, takes what it hands on. The wrapper's ``new`` takes a 
scalar
+        field as its own type and any other as ``impl Into`` 
(:func:`_param_type`), as
+        ``#[rs_class]``'s ``new`` does, and converts it on the way to 
``<Leaf>Obj::new``.
         ``custom-new`` names the wrapper's allocator ``from_fields`` instead.
+        ``<Leaf>Obj::new`` of a type that is not final is public: a subtype 
defined in Rust
+        builds its parent's object with it.
         """
         inherited: list[tuple[str, str]] = []
         if has_parent:
@@ -510,13 +543,13 @@ class _ObjectRenderer:
             f"{rust_ident(name)}.into()" if rust_type != parent_type else 
rust_ident(name)
             for (name, rust_type), (_, parent_type) in zip(params, inherited)
         ]
-        own = [rust_ident(f.name) for f in sorted(self.info.fields, key=lambda 
f: f.offset or 0)]
-        forward = [rust_ident(name) for name, _ in params]
+        own = [rust_ident(name) for name, _ in params[len(inherited) :]]
+        forward = [_forward(rust_ident(name), rust_type) for name, rust_type 
in params]
         sections = [
             [
                 f"impl {self.obj_struct} {{",
                 *self._fn_lines(
-                    "pub(crate) fn new",
+                    "pub(crate) fn new" if self.info.is_final else "pub fn 
new",
                     params,
                     (f"base = {base}::new", to_parent),
                     ["base", *own],
@@ -534,12 +567,40 @@ class _ObjectRenderer:
                     params,
                     (f"obj = {self.obj_struct}::new", forward),
                     ["base: ObjectArc::new(obj)"],
+                    generic=True,
                 ),
                 "}",
             ]
         )
         return sections
 
+    def _shallow_copy_lines(self) -> list[str] | None:
+        """``ShallowCopy``, which a subtype defined in Rust needs of its 
parent's object.
+
+        The parent's shallow copy and a clone of every field, as 
``derive(Object)``
+        implements it, for a type that is not final and whose ancestors below
+        ``ffi.Object`` are all generated: each of those implements it by this 
rule,
+        while a ``ty-map``'d one is hand-written and may not.
+        """
+        if self.info.is_final:
+            return None
+        ancestors = [key for key in self.info.ancestors if key != 
C_RUST.RUST_ROOT_TYPE_KEY]
+        if not all(self._provider(key) == "generated" for key in ancestors):
+            return None
+        shallow_copy = self.imports.record("tvm_ffi::ShallowCopy")
+        own = [rust_ident(f.name) for f in sorted(self.info.fields, key=lambda 
f: f.offset or 0)]
+        init = [
+            f"base: {shallow_copy}::shallow_copy(&self.base)",
+            *[f"{name}: Clone::clone(&self.{name})" for name in own],
+        ]
+        return [
+            f"impl {shallow_copy} for {self.obj_struct} {{",
+            "    fn shallow_copy(&self) -> Self {",
+            *_call_lines("        Self { ", init, " }"),
+            "    }",
+            "}",
+        ]
+
     def body(self) -> list[str]:
         """Build the Rust source lines for the object."""
         self.imports.reserved.update((self.leaf, self.obj_struct))
@@ -590,6 +651,9 @@ class _ObjectRenderer:
             )
         elif verdict.is_complete:
             sections += self._allocator_sections(base, has_parent)
+            shallow_copy = self._shallow_copy_lines()
+            if shallow_copy:
+                sections.append(shallow_copy)
         upcasts = self._upcast_lines()
         if upcasts:
             sections.append(upcasts)
diff --git a/python/tvm_ffi/stub/rust_generator/consts.py 
b/python/tvm_ffi/stub/rust_generator/consts.py
index 0ab2a11d..aea485d5 100644
--- a/python/tvm_ffi/stub/rust_generator/consts.py
+++ b/python/tvm_ffi/stub/rust_generator/consts.py
@@ -85,6 +85,13 @@ RUST_SCALAR_WIDTHS = {
     "f64": 8,
 }
 
+#: Parameters of the wrapper's allocator taken as their own type, as 
``#[rs_class]``'s
+#: ``new`` takes them: a literal infers only from its exact type. Any other 
parameter is
+#: ``impl Into<T>``; ``<Leaf>Obj::new`` takes every one as its own type.
+RUST_EXACT_PARAMS = frozenset(
+    {"bool", "i8", "i16", "i32", "i64", "isize", "u8", "u16", "u32", "u64", 
"usize", "f32", "f64"}
+)
+
 #: Size of an object reference field; ``nullable`` may only wrap those.
 RUST_POINTER_SIZE = 8
 
diff --git a/rust/tvm-ffi-macros/src/def_object_ref.rs 
b/rust/tvm-ffi-macros/src/def_object_ref.rs
new file mode 100644
index 00000000..9d02966c
--- /dev/null
+++ b/rust/tvm-ffi-macros/src/def_object_ref.rs
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+use proc_macro::TokenStream;
+use proc_macro2::TokenStream as TokenStream2;
+use quote::quote;
+use syn::parse::{Parse, ParseStream};
+use syn::{parse_macro_input, Attribute, DeriveInput, Ident, Path, Token, Type, 
Visibility};
+
+use crate::object_macros::expand_object_ref;
+use crate::utils::get_tvm_ffi_crate;
+
+pub(crate) fn def_object_ref(input: TokenStream) -> TokenStream {
+    let args = parse_macro_input!(input as DefObjectRefArgs);
+    expand(&args)
+        .unwrap_or_else(|error| error.to_compile_error())
+        .into()
+}
+
+/// `def_object_ref!(#[attrs] pub TypeName, ParentType, ObjectName, crate = 
path)`.
+struct DefObjectRefArgs {
+    attrs: Vec<Attribute>,
+    vis: Visibility,
+    type_name: Ident,
+    parent_type: Type,
+    object_name: Type,
+    tvm_ffi_crate: Option<Path>,
+}
+
+impl Parse for DefObjectRefArgs {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let attrs = input.call(Attribute::parse_outer)?;
+        let vis = input.parse()?;
+        let type_name = input.parse()?;
+        input.parse::<Token![,]>()?;
+        let parent_type = input.parse()?;
+        input.parse::<Token![,]>()?;
+        let object_name = input.parse()?;
+        let mut tvm_ffi_crate = None;
+        if input.parse::<Option<Token![,]>>()?.is_some() && 
input.peek(Token![crate]) {
+            input.parse::<Token![crate]>()?;
+            input.parse::<Token![=]>()?;
+            tvm_ffi_crate = Some(Path::parse_mod_style(input)?);
+            input.parse::<Option<Token![,]>>()?;
+        }
+        if !input.is_empty() {
+            return Err(input.error("expected `crate = <path>` after the object 
type"));
+        }
+        Ok(DefObjectRefArgs {
+            attrs,
+            vis,
+            type_name,
+            parent_type,
+            object_name,
+            tvm_ffi_crate,
+        })
+    }
+}
+
+fn expand(args: &DefObjectRefArgs) -> syn::Result<TokenStream2> {
+    let tvm_ffi = match &args.tvm_ffi_crate {
+        Some(path) => quote!(#path),
+        None => get_tvm_ffi_crate(),
+    };
+    let DefObjectRefArgs {
+        attrs,
+        vis,
+        type_name,
+        parent_type,
+        object_name,
+        ..
+    } = args;
+    let ref_input: DeriveInput = syn::parse2(quote! {
+        #vis struct #type_name {
+            base: #tvm_ffi::object::ObjectArc<#object_name>,
+        }
+    })?;
+    let ref_impl = expand_object_ref(&ref_input, &tvm_ffi);
+    let doc = if attrs.iter().any(|a| a.path.is_ident("doc")) {
+        None
+    } else {
+        let doc = format!(
+            "The reference to [`{}`].",
+            quote!(#object_name).to_string().replace(' ', "")
+        );
+        Some(quote!(#[doc = #doc]))
+    };
+
+    Ok(quote! {
+        #doc
+        #(#attrs)*
+        #[repr(C)]
+        #[derive(::core::clone::Clone)]
+        #vis struct #type_name {
+            base: #tvm_ffi::object::ObjectArc<#object_name>,
+        }
+
+        #ref_impl
+
+        impl ::core::ops::Deref for #type_name {
+            type Target = #object_name;
+            #[inline]
+            fn deref(&self) -> &#object_name {
+                &self.base
+            }
+        }
+
+        impl ::core::convert::From<#object_name> for #type_name {
+            #[inline]
+            fn from(object: #object_name) -> Self {
+                Self {
+                    base: #tvm_ffi::object::ObjectArc::new(object),
+                }
+            }
+        }
+
+        #tvm_ffi::impl_object_upcast!(#type_name => #parent_type);
+    })
+}
diff --git a/rust/tvm-ffi-macros/src/lib.rs b/rust/tvm-ffi-macros/src/lib.rs
index a38a7729..5af23be1 100644
--- a/rust/tvm-ffi-macros/src/lib.rs
+++ b/rust/tvm-ffi-macros/src/lib.rs
@@ -20,9 +20,11 @@
 use proc_macro::TokenStream;
 use proc_macro_error::proc_macro_error;
 
+mod def_object_ref;
 mod dispatch;
 mod match_any;
 mod object_macros;
+mod rs_class;
 mod utils;
 
 /// Generate `walk`, `map`, `visit`, or `mutate` dispatch from an inherent 
impl.
@@ -110,3 +112,100 @@ pub fn derive_object(input: TokenStream) -> TokenStream {
 pub fn derive_object_ref(input: TokenStream) -> TokenStream {
     object_macros::derive_object_ref(input)
 }
+
+/// Define an object type in Rust from its object struct, as Python's
+/// `py_class` does: the type registers its key with the reflection of every
+/// field, so that C++ structural equality and hashing, `repr`, deep copy and
+/// Python see the fields.
+///
+/// ```ignore
+/// use tvm_ffi::{def_object_ref, rs_class};
+///
+/// /// A pair of values under a `Base`.
+/// #[rs_class("my_ext.Pair", final)]
+/// pub struct PairObj {
+///     base: BaseObj,
+///     pub first: tvm_ffi::String,
+///     pub second: tvm_ffi::String,
+///     #[reflect(seq_hash_ignore)]
+///     pub note: tvm_ffi::String,
+/// }
+///
+/// def_object_ref!(pub Pair, Base, PairObj);
+///
+/// let pair = Pair::from(PairObj::new(BaseObj::new(/* ... */), "a", "b", ""));
+/// let base = Base::from(pair);
+/// ```
+///
+/// The first field, `base`, is the parent's object, as the base class of a C++
+/// object is: `base: Object` for a type under `ffi.Object`. The parent's 
object
+/// must implement `ShallowCopy`, as `rs_class` types and non-final C++ types
+/// bound by `tvm-ffi-stubgen` do. Every other field is reflected;
+/// `#[reflect(writable, seq_hash_ignore, ...)]` gives a field the
+/// [`FieldFlags`] of those names. The macro adds to the struct:
+///
+/// - `#[repr(C)]` and `Deref` to the parent's object, so `obj.field` reads the
+///   fields of both.
+/// - `PairObj::new(base, ...)`, which builds the object from the parent's
+///   object and the fields in order: a scalar (`bool`, an integer or a float)
+///   as its own type, so that a literal infers as it, and any other field from
+///   anything that converts into it, as the `new` of a type bound by
+///   `tvm-ffi-stubgen` takes them.
+///
+/// [`def_object_ref!`] defines the reference to it and its conversion into the
+/// reference to its parent.
+///
+/// After the type key:
+///
+/// - `final`, `s_eq_hash_kind = "TreeNode"`, `child_slots = N` and
+///   `child_slots_can_overflow = bool` are `derive(Object)`'s `#[type_final]`,
+///   `#[type_s_eq_hash_kind]`, `#[type_child_slots]` and
+///   `#[type_child_slots_can_overflow]`.
+/// - `crate = path` is where the generated code finds tvm-ffi
+///   (`crate = ::my_lib::tvm_ffi`), for a crate that depends on it only 
through
+///   a re-export; by default `tvm_ffi`.
+///
+/// The type is a `derive(Object)` type with `#[type_register]`: it registers 
on
+/// its first `type_index()`, with the fields, `__ffi_shallow_copy__` and
+/// `__ffi_type_final__`, and without `__ffi_init__`. A field's type must
+/// implement `AnyCompatible` and `Clone`.
+///
+/// [`FieldFlags`]: ../tvm_ffi/reflection/struct.FieldFlags.html
+#[proc_macro_error]
+#[proc_macro_attribute]
+pub fn rs_class(args: TokenStream, item: TokenStream) -> TokenStream {
+    rs_class::rs_class(args, item)
+}
+
+/// Define the reference to an object type, as C++
+/// `TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE` does:
+/// `def_object_ref!(TypeName, ParentType, ObjectName)`.
+///
+/// ```ignore
+/// use tvm_ffi::def_object_ref;
+///
+/// def_object_ref!(
+///     /// A pair of values.
+///     pub Pair,
+///     Base,
+///     PairObj
+/// );
+/// ```
+///
+/// `Pair` holds a `PairObj` and is a `derive(ObjectRef)` type. It dereferences
+/// to `PairObj`, and a `PairObj` converts into it.
+///
+/// `ParentType` is the reference to the parent's object: `Base` here, and
+/// `tvm_ffi::object::ObjectRef` for a type under `ffi.Object`. `Pair` converts
+/// into it by `impl_object_upcast!`, so its object must be a registered base 
of
+/// `PairObj`, as that macro requires. A conversion into the reference to a
+/// further ancestor takes its own `impl_object_upcast!`.
+///
+/// Attributes and visibility before the name go on the struct. `crate = path`
+/// after the object type is where the generated code finds tvm-ffi, as for
+/// [`rs_class`].
+#[proc_macro_error]
+#[proc_macro]
+pub fn def_object_ref(input: TokenStream) -> TokenStream {
+    def_object_ref::def_object_ref(input)
+}
diff --git a/rust/tvm-ffi-macros/src/object_macros.rs 
b/rust/tvm-ffi-macros/src/object_macros.rs
index f4d6324d..9064cf81 100644
--- a/rust/tvm-ffi-macros/src/object_macros.rs
+++ b/rust/tvm-ffi-macros/src/object_macros.rs
@@ -17,6 +17,7 @@
  * under the License.
  */
 use proc_macro::TokenStream;
+use proc_macro2::TokenStream as TokenStream2;
 use quote::{format_ident, quote};
 use syn::ext::IdentExt;
 use syn::DeriveInput;
@@ -25,14 +26,26 @@ use crate::utils::*;
 
 /// Derive Object trait for a struct to generate boilerplate code
 pub fn derive_object(input: proc_macro::TokenStream) -> TokenStream {
-    let tvm_ffi_crate = get_tvm_ffi_crate();
     let derive_input = syn::parse_macro_input!(input as DeriveInput);
+    TokenStream::from(expand_object(&derive_input, &get_tvm_ffi_crate(), 
false))
+}
+
+/// The `ObjectCore` impl of an object struct, and its `ShallowCopy` when it
+/// registers reflection, naming the tvm-ffi crate `tvm_ffi_crate`.
+///
+/// `reflect_always` registers the reflection of a type without `#[reflect]`
+/// fields or `#[type_s_eq_hash_kind]` too, as `#[rs_class]` does.
+pub(crate) fn expand_object(
+    derive_input: &DeriveInput,
+    tvm_ffi_crate: &TokenStream2,
+    reflect_always: bool,
+) -> TokenStream2 {
     let struct_name = derive_input.ident.clone();
 
-    let type_key = get_attr(&derive_input, "type_key")
+    let type_key = get_attr(derive_input, "type_key")
         .map(attr_to_str)
         .expect("Expect #[type_key = \"<my_type_key>\"] attribute");
-    let type_final = match get_attr(&derive_input, "type_final") {
+    let type_final = match get_attr(derive_input, "type_final") {
         Some(attr) if matches!(attr.parse_meta(), Ok(syn::Meta::Path(_))) => 
true,
         Some(_) => panic!("Expect #[type_final] attribute"),
         None => false,
@@ -41,7 +54,7 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
     // Whether this type is defined here, so that it registers its key, as C++
     // `TVM_FFI_DECLARE_OBJECT_INFO` does. Without it, the type binds a key
     // that its defining library, usually C++, has registered.
-    let type_register = match get_attr(&derive_input, "type_register") {
+    let type_register = match get_attr(derive_input, "type_register") {
         Some(attr) if matches!(attr.parse_meta(), Ok(syn::Meta::Path(_))) => 
true,
         Some(_) => panic!("Expect #[type_register] attribute"),
         None => false,
@@ -49,7 +62,7 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
 
     // Reserved child slots and whether children may exceed them, as C++
     // `_type_child_slots` and `_type_child_slots_can_overflow` declare them.
-    let child_slots = match get_attr(&derive_input, 
"type_child_slots").map(|a| a.parse_meta()) {
+    let child_slots = match get_attr(derive_input, "type_child_slots").map(|a| 
a.parse_meta()) {
         Some(Ok(syn::Meta::NameValue(syn::MetaNameValue {
             lit: syn::Lit::Int(n),
             ..
@@ -60,7 +73,7 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
         None => 0,
     };
     let child_slots_can_overflow =
-        match get_attr(&derive_input, "type_child_slots_can_overflow").map(|a| 
a.parse_meta()) {
+        match get_attr(derive_input, "type_child_slots_can_overflow").map(|a| 
a.parse_meta()) {
             Some(Ok(syn::Meta::NameValue(syn::MetaNameValue {
                 lit: syn::Lit::Bool(b),
                 ..
@@ -78,8 +91,8 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
     );
     assert!(
         type_register
-            || (get_attr(&derive_input, "type_child_slots").is_none()
-                && get_attr(&derive_input, 
"type_child_slots_can_overflow").is_none()),
+            || (get_attr(derive_input, "type_child_slots").is_none()
+                && get_attr(derive_input, 
"type_child_slots_can_overflow").is_none()),
         "#[type_child_slots] and #[type_child_slots_can_overflow] need 
#[type_register]"
     );
     // we expect base always to be the first field
@@ -89,15 +102,15 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
     }
     .expect("First field must be `<base_name>: <ObjectCoreType>`");
 
-    let reflection = Reflection::parse(&derive_input);
+    let reflection = Reflection::parse(derive_input, reflect_always);
     assert!(
-        reflection.is_none() || (type_register && get_attr(&derive_input, 
"type_index").is_none()),
+        reflection.is_none() || (type_register && get_attr(derive_input, 
"type_index").is_none()),
         "#[reflect] and #[type_s_eq_hash_kind] need #[type_register] and no 
#[type_index]"
     );
     let (register_reflection, shallow_copy_impl) = match &reflection {
         Some(reflection) => (
-            reflection.register_tokens(&struct_name, &type_key, 
&tvm_ffi_crate),
-            reflection.shallow_copy_tokens(&struct_name, &base_ty, 
&tvm_ffi_crate),
+            reflection.register_tokens(&struct_name, &type_key, tvm_ffi_crate),
+            reflection.shallow_copy_tokens(&struct_name, &base_ty, 
tvm_ffi_crate),
         ),
         None => (quote! {}, quote! {}),
     };
@@ -106,7 +119,7 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
     // under its parent on first use, or takes the index the key already has,
     // as C++ `TVM_FFI_DECLARE_OBJECT_INFO` does; any other type binds the
     // index its defining library registered.
-    let type_index_tokens = match get_attr(&derive_input, 
"type_index").map(attr_to_expr) {
+    let type_index_tokens = match get_attr(derive_input, 
"type_index").map(attr_to_expr) {
         Some(type_index) => {
             let type_index_expr =
                 type_index.expect("Expect 
#[type_index(TypeIndex::<my_type_index>)] attribute");
@@ -213,14 +226,16 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
                         fn assert_impl<T: 
#tvm_ffi_crate::object::ObjectCore>() {}
                         let _ = assert_impl::<#base_ty>;
                     };
-                    #base_ty::object_header_mut(&mut this.#base_id)
+                    <#base_ty as 
#tvm_ffi_crate::object::ObjectCore>::object_header_mut(
+                        &mut this.#base_id
+                    )
                 }
             })
         }),
         _ => panic!("First field must be `<base_name>: <ObjectCoreType>`"),
     };
 
-    let expanded = quote! {
+    quote! {
         #final_parent_check
 
         unsafe impl #tvm_ffi_crate::object::ObjectCore for #struct_name {
@@ -233,8 +248,7 @@ pub fn derive_object(input: proc_macro::TokenStream) -> 
TokenStream {
         }
 
         #shallow_copy_impl
-    };
-    TokenStream::from(expanded)
+    }
 }
 
 /// The reflection a `#[type_register]` type declares: its `#[reflect]` fields
@@ -250,8 +264,9 @@ struct Reflection {
 }
 
 impl Reflection {
-    /// The type's reflection, or `None` when it declares none.
-    fn parse(derive_input: &DeriveInput) -> Option<Self> {
+    /// The type's reflection, or `None` when it declares none and `always`
+    /// is false.
+    fn parse(derive_input: &DeriveInput, always: bool) -> Option<Self> {
         let kind = get_attr(derive_input, "type_s_eq_hash_kind").map(|attr| {
             let kind = attr_to_str(attr);
             syn::Ident::new(&kind.value(), kind.span())
@@ -293,7 +308,7 @@ impl Reflection {
             };
             reflected.push((id, field.ty.clone(), flags));
         }
-        if kind.is_none() && reflected.is_empty() {
+        if kind.is_none() && reflected.is_empty() && !always {
             return None;
         }
         Some(Self {
@@ -372,8 +387,16 @@ impl Reflection {
 
 /// Derive ObjectRef trait for a struct to generate boilerplate code
 pub fn derive_object_ref(input: proc_macro::TokenStream) -> TokenStream {
-    let tvm_ffi_crate = get_tvm_ffi_crate();
     let derive_input = syn::parse_macro_input!(input as DeriveInput);
+    TokenStream::from(expand_object_ref(&derive_input, &get_tvm_ffi_crate()))
+}
+
+/// The `ObjectRefCore` and `AnyCompatible` impls of a reference struct,
+/// naming the tvm-ffi crate `tvm_ffi_crate`.
+pub(crate) fn expand_object_ref(
+    derive_input: &DeriveInput,
+    tvm_ffi_crate: &TokenStream2,
+) -> TokenStream2 {
     let struct_name = derive_input.ident.clone();
 
     // The `ObjectArc<T>` slot is the first field; its name is the struct's 
choice
@@ -530,5 +553,5 @@ pub fn derive_object_ref(input: proc_macro::TokenStream) -> 
TokenStream {
             #tvm_ffi_crate::impl_into_arg_holder_default!(#struct_name);
         });
     }
-    TokenStream::from(expanded)
+    expanded
 }
diff --git a/rust/tvm-ffi-macros/src/rs_class.rs 
b/rust/tvm-ffi-macros/src/rs_class.rs
new file mode 100644
index 00000000..b9aa39ba
--- /dev/null
+++ b/rust/tvm-ffi-macros/src/rs_class.rs
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+use proc_macro::TokenStream;
+use proc_macro2::TokenStream as TokenStream2;
+use quote::quote;
+use syn::ext::IdentExt;
+use syn::parse::{Parse, ParseStream};
+use syn::{parse_macro_input, Data, DeriveInput, Fields, LitBool, LitInt, 
LitStr, Path, Token};
+
+use crate::object_macros::expand_object;
+use crate::utils::get_tvm_ffi_crate;
+
+pub(crate) fn rs_class(args: TokenStream, item: TokenStream) -> TokenStream {
+    let args = parse_macro_input!(args as RsClassArgs);
+    let item = parse_macro_input!(item as DeriveInput);
+    expand(&args, &item)
+        .unwrap_or_else(|error| error.to_compile_error())
+        .into()
+}
+
+/// `#[rs_class("type.key", final, ...)]`.
+struct RsClassArgs {
+    type_key: LitStr,
+    type_final: bool,
+    s_eq_hash_kind: Option<LitStr>,
+    child_slots: Option<LitInt>,
+    child_slots_can_overflow: Option<LitBool>,
+    tvm_ffi_crate: Option<Path>,
+}
+
+impl Parse for RsClassArgs {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let type_key = input.parse::<LitStr>().map_err(|error| {
+            syn::Error::new(
+                error.span(),
+                "expected the type key first, as in 
`#[rs_class(\"my.Point\")]`",
+            )
+        })?;
+        let mut args = RsClassArgs {
+            type_key,
+            type_final: false,
+            s_eq_hash_kind: None,
+            child_slots: None,
+            child_slots_can_overflow: None,
+            tvm_ffi_crate: None,
+        };
+        let mut seen = Vec::new();
+        while !input.is_empty() {
+            input.parse::<Token![,]>()?;
+            if input.is_empty() {
+                break;
+            }
+            // `final` and `crate` are keywords.
+            let name = input.call(syn::Ident::parse_any)?;
+            let key = name.to_string();
+            if seen.contains(&key) {
+                return Err(syn::Error::new(name.span(), format!("duplicate 
`{key}`")));
+            }
+            if key != "final" {
+                input.parse::<Token![=]>()?;
+            }
+            match key.as_str() {
+                "final" => args.type_final = true,
+                "s_eq_hash_kind" => args.s_eq_hash_kind = Some(input.parse()?),
+                "child_slots" => args.child_slots = Some(input.parse()?),
+                "child_slots_can_overflow" => args.child_slots_can_overflow = 
Some(input.parse()?),
+                "crate" => args.tvm_ffi_crate = 
Some(Path::parse_mod_style(input)?),
+                _ => {
+                    return Err(syn::Error::new(
+                        name.span(),
+                        "expected `final`, `s_eq_hash_kind = \"<kind>\"`, 
`child_slots = <n>`, \
+                         `child_slots_can_overflow = <bool>` or `crate = 
<path>`",
+                    ));
+                }
+            }
+            seen.push(key);
+        }
+        if args.type_final
+            && (args.child_slots.is_some() || 
args.child_slots_can_overflow.is_some())
+        {
+            return Err(syn::Error::new(
+                args.type_key.span(),
+                "a final type has no children to reserve slots for",
+            ));
+        }
+        Ok(args)
+    }
+}
+
+const EXPECTED_BASE: &str =
+    "the first field holds the parent's object: `base: Object` at the root, 
else `base: ParentObj`";
+
+fn expand(args: &RsClassArgs, item: &DeriveInput) -> syn::Result<TokenStream2> 
{
+    if !item.generics.params.is_empty() || 
item.generics.where_clause.is_some() {
+        return Err(syn::Error::new_spanned(
+            &item.generics,
+            "an `rs_class` cannot be generic",
+        ));
+    }
+    let Data::Struct(data) = &item.data else {
+        return Err(syn::Error::new(
+            item.ident.span(),
+            "`rs_class` expects a struct",
+        ));
+    };
+    let fields: Vec<&syn::Field> = match &data.fields {
+        Fields::Named(fields) => fields.named.iter().collect(),
+        Fields::Unit => Vec::new(),
+        Fields::Unnamed(fields) => {
+            return Err(syn::Error::new_spanned(
+                fields,
+                "`rs_class` expects named fields",
+            ));
+        }
+    };
+    let (base, fields) = match fields.split_first() {
+        Some((base, fields)) if base.ident.as_ref().is_some_and(|id| 
id.unraw() == "base") => {
+            (*base, fields)
+        }
+        Some((field, _)) => return Err(syn::Error::new_spanned(field, 
EXPECTED_BASE)),
+        None => return Err(syn::Error::new(item.ident.span(), EXPECTED_BASE)),
+    };
+    if let Some(attr) = base.attrs.iter().find(|a| a.path.is_ident("reflect")) 
{
+        return Err(syn::Error::new_spanned(
+            attr,
+            "`base` is the parent's object, whose fields the parent reflects",
+        ));
+    }
+    let base_id = &base.ident;
+    let base_ty = &base.ty;
+    // `new` takes a scalar as its own type, so that a literal infers as it, 
and any
+    // other field as `impl Into`.
+    let params = fields.iter().map(|field| {
+        let (name, ty) = (&field.ident, &field.ty);
+        if is_exact_param(ty) {
+            quote!(#name: #ty)
+        } else {
+            quote!(#name: impl ::core::convert::Into<#ty>)
+        }
+    });
+    let inits = fields.iter().map(|field| {
+        let name = &field.ident;
+        if is_exact_param(&field.ty) {
+            quote!(#name)
+        } else {
+            quote!(#name: ::core::convert::Into::into(#name))
+        }
+    });
+
+    let tvm_ffi = match &args.tvm_ffi_crate {
+        Some(path) => quote!(#path),
+        None => get_tvm_ffi_crate(),
+    };
+    let attrs = &item.attrs;
+    let vis = &item.vis;
+    let obj = &item.ident;
+    let key = &args.type_key;
+
+    // `derive(Object)` reads the type from these attributes; the struct 
written out
+    // carries neither them nor `#[reflect]`, which no derive on it would take.
+    let mut derive_attrs = vec![quote!(#[type_key = #key]), 
quote!(#[type_register])];
+    if args.type_final {
+        derive_attrs.push(quote!(#[type_final]));
+    }
+    if let Some(kind) = &args.s_eq_hash_kind {
+        derive_attrs.push(quote!(#[type_s_eq_hash_kind = #kind]));
+    }
+    if let Some(slots) = &args.child_slots {
+        derive_attrs.push(quote!(#[type_child_slots = #slots]));
+    }
+    if let Some(overflow) = &args.child_slots_can_overflow {
+        derive_attrs.push(quote!(#[type_child_slots_can_overflow = 
#overflow]));
+    }
+    let reflected_fields = fields.iter().map(|field| {
+        if field.attrs.iter().any(|a| a.path.is_ident("reflect")) {
+            quote!(#field)
+        } else {
+            quote!(#[reflect] #field)
+        }
+    });
+    let object_input: DeriveInput = syn::parse2(quote! {
+        #(#derive_attrs)*
+        #vis struct #obj {
+            #base,
+            #(#reflected_fields,)*
+        }
+    })?;
+    let object_impl = expand_object(&object_input, &tvm_ffi, true);
+    let plain_fields = fields.iter().map(|field| {
+        let mut field = (*field).clone();
+        field.attrs.retain(|a| !a.path.is_ident("reflect"));
+        field
+    });
+
+    Ok(quote! {
+        #(#attrs)*
+        #[repr(C)]
+        #vis struct #obj {
+            #base,
+            #(#plain_fields,)*
+        }
+
+        #object_impl
+
+        impl ::core::ops::Deref for #obj {
+            type Target = #base_ty;
+            #[inline]
+            fn deref(&self) -> &#base_ty {
+                &self.#base_id
+            }
+        }
+
+        impl #obj {
+            /// The object from the parent's object, then the fields in order: 
a
+            /// scalar as its own type, any other from anything that converts 
into it.
+            #[allow(clippy::too_many_arguments)]
+            pub fn new(#base_id: #base_ty, #(#params),*) -> Self {
+                Self { #base_id, #(#inits,)* }
+            }
+        }
+    })
+}
+
+/// The scalars `new` takes as their own type, as the allocator of a type 
bound by
+/// tvm-ffi-stubgen does (`RUST_EXACT_PARAMS` there): a literal infers only 
from
+/// its exact type.
+const EXACT_PARAMS: &[&str] = &[
+    "bool", "i8", "i16", "i32", "i64", "isize", "u8", "u16", "u32", "u64", 
"usize", "f32", "f64",
+];
+
+fn is_exact_param(ty: &syn::Type) -> bool {
+    match ty {
+        syn::Type::Path(path) if path.qself.is_none() => path
+            .path
+            .get_ident()
+            .is_some_and(|ident| EXACT_PARAMS.iter().any(|name| ident == 
name)),
+        _ => false,
+    }
+}
diff --git a/rust/tvm-ffi/src/lib.rs b/rust/tvm-ffi/src/lib.rs
index 7ed07315..c6310957 100644
--- a/rust/tvm-ffi/src/lib.rs
+++ b/rust/tvm-ffi/src/lib.rs
@@ -78,7 +78,7 @@ pub use crate::reflection::{
 pub use crate::rvalue_ref::RValueRef;
 pub use crate::string::{Bytes, String};
 pub use crate::type_traits::AnyCompatible;
-pub use tvm_ffi_macros::{dispatch, match_any};
+pub use tvm_ffi_macros::{def_object_ref, dispatch, match_any, rs_class};
 
 pub use tvm_ffi_sys::TVMFFITypeIndex as TypeIndex;
 pub use tvm_ffi_sys::{
diff --git a/rust/tvm-ffi/tests/test_rs_class.rs 
b/rust/tvm-ffi/tests/test_rs_class.rs
new file mode 100644
index 00000000..5cb5583c
--- /dev/null
+++ b/rust/tvm-ffi/tests/test_rs_class.rs
@@ -0,0 +1,338 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+use tvm_ffi::derive::Object;
+use tvm_ffi::object::ObjectRef;
+use tvm_ffi::reflection::FieldFlags;
+use tvm_ffi::*;
+use tvm_ffi_sys::{TVMFFIFieldInfo, TVMFFIGetTypeInfo, TVMFFISEqHashKind, 
TVMFFITypeInfo};
+
+// Object types defined in Rust by `#[rs_class]`, whose fields the C++
+// structural engines, `repr` and deep copy see.
+
+/// A root type, under `ffi.Object`.
+#[rs_class("testing.rust.RsPoint", s_eq_hash_kind = "TreeNode")]
+pub struct RsPointObj {
+    base: Object,
+    pub x: i64,
+    #[reflect(writable, seq_hash_ignore)]
+    pub label: tvm_ffi::String,
+}
+
+def_object_ref!(pub RsPoint, ObjectRef, RsPointObj);
+
+/// A subtype of a Rust-defined type, with a private field.
+#[rs_class("testing.rust.RsPoint3", final)]
+pub struct RsPoint3Obj {
+    base: RsPointObj,
+    pub z: i64,
+    pub tags: Array<tvm_ffi::String>,
+    tag: i64,
+}
+
+def_object_ref!(
+    /// The reference with its own docs.
+    pub RsPoint3,
+    RsPoint,
+    RsPoint3Obj,
+);
+
+/// A subtype without fields, which still registers its reflection.
+#[rs_class("testing.rust.RsMarker", final)]
+pub struct RsMarkerObj {
+    base: RsPointObj,
+}
+
+def_object_ref!(pub RsMarker, RsPoint, RsMarkerObj);
+
+/// Scalar fields, whose literal arguments to `new` infer as the field types.
+#[rs_class("testing.rust.RsScalars", final)]
+pub struct RsScalarsObj {
+    base: Object,
+    pub big: i64,
+    pub count: u64,
+    pub port: u16,
+    pub ratio: f32,
+}
+
+def_object_ref!(pub RsScalars, ObjectRef, RsScalarsObj);
+
+// `testing.TestCxxClassBase` from libtvm_ffi_testing, bound as tvm-ffi-stubgen
+// binds a complete C++ type that can have children: with a public `new` and
+// `ShallowCopy`, so that a Rust type can derive from it.
+
+#[repr(C)]
+#[derive(Object)]
+#[type_key = "testing.TestCxxClassBase"]
+pub struct TestCxxClassBaseObj {
+    base: Object,
+    pub v_i64: i64,
+    pub v_i32: i32,
+}
+
+impl TestCxxClassBaseObj {
+    pub fn new(v_i64: i64, v_i32: i32) -> Self {
+        let base = Object::new();
+        Self { base, v_i64, v_i32 }
+    }
+}
+
+impl ShallowCopy for TestCxxClassBaseObj {
+    fn shallow_copy(&self) -> Self {
+        Self {
+            base: ShallowCopy::shallow_copy(&self.base),
+            v_i64: Clone::clone(&self.v_i64),
+            v_i32: Clone::clone(&self.v_i32),
+        }
+    }
+}
+
+// `def_object_ref!` takes any object type, not only an `rs_class` one.
+def_object_ref!(pub TestCxxClassBase, ObjectRef, TestCxxClassBaseObj);
+
+/// A subtype of a C++ type.
+#[rs_class("testing.rust.RsCxxChild", final, s_eq_hash_kind = "TreeNode")]
+pub struct RsCxxChildObj {
+    base: TestCxxClassBaseObj,
+    pub name: tvm_ffi::String,
+}
+
+def_object_ref!(pub RsCxxChild, TestCxxClassBase, RsCxxChildObj);
+
+/// A crate that reaches tvm-ffi only through a re-export names it with 
`crate`.
+mod reexported {
+    // The bare `tvm_ffi` here is not the crate, as in a crate without the 
dependency.
+    #[allow(dead_code)]
+    mod tvm_ffi {}
+
+    #[::tvm_ffi::rs_class("testing.rust.RsReexported", crate = ::tvm_ffi)]
+    pub struct ReexportedObj {
+        base: ::tvm_ffi::Object,
+        pub v: i64,
+    }
+
+    ::tvm_ffi::def_object_ref!(
+        pub Reexported,
+        ::tvm_ffi::object::ObjectRef,
+        ReexportedObj,
+        crate = ::tvm_ffi
+    );
+}
+
+fn point(x: i64, label: &str) -> RsPoint {
+    RsPointObj::new(Object::new(), x, label).into()
+}
+
+fn point3(x: i64, z: i64) -> RsPoint3 {
+    let tags = Array::new(vec!["a".into(), "b".into()]);
+    RsPoint3Obj::new(RsPointObj::new(Object::new(), x, "p3"), z, tags, 
7).into()
+}
+
+/// The dummy call keeps libtvm_ffi_testing, which registers 
`testing.TestCxxClassBase`, linked.
+fn cxx_child(v_i64: i64, v_i32: i32, name: &str) -> RsCxxChild {
+    assert_eq!(unsafe { tvm_ffi_sys::TVMFFITestingDummyTarget() }, 0);
+    RsCxxChildObj::new(TestCxxClassBaseObj::new(v_i64, v_i32), name).into()
+}
+
+fn type_info<N: ObjectCore>() -> &'static TVMFFITypeInfo {
+    unsafe { &*TVMFFIGetTypeInfo(N::type_index()) }
+}
+
+fn field_names<N: ObjectCore>() -> Vec<&'static str> {
+    fields(type_info::<N>())
+        .iter()
+        .map(|f| f.name.as_str())
+        .collect()
+}
+
+fn fields(info: &TVMFFITypeInfo) -> &[TVMFFIFieldInfo] {
+    if info.num_fields == 0 {
+        return &[]; // `fields` may be null
+    }
+    unsafe { std::slice::from_raw_parts(info.fields, info.num_fields as usize) 
}
+}
+
+fn call(name: &str, args: &[AnyView]) -> Any {
+    Function::get_global(name)
+        .unwrap()
+        .call_packed(args)
+        .unwrap()
+}
+
+fn structural_equal<T: AnyCompatible + Clone>(a: &T, b: &T) -> bool {
+    let (a, b) = (Any::from(a.clone()), Any::from(b.clone()));
+    let args = [
+        AnyView::from(&a),
+        AnyView::from(&b),
+        AnyView::from(&false),
+        AnyView::from(&false),
+    ];
+    bool::try_from(call("ffi.StructuralEqual", &args)).unwrap()
+}
+
+fn deep_copy<T: AnyCompatible + Clone + TryFrom<Any, Error = Error>>(value: 
&T) -> T {
+    let any = Any::from(value.clone());
+    T::try_from(call("ffi.DeepCopy", &[AnyView::from(&any)])).unwrap()
+}
+
+#[test]
+fn test_fields_are_registered_with_their_flags() {
+    assert_eq!(field_names::<RsPointObj>(), ["x", "label"]);
+    let [x, label] = fields(type_info::<RsPointObj>()) else {
+        unreachable!()
+    };
+    assert_eq!(x.flags, FieldFlags::NONE.bits());
+    assert_eq!(x.offset, std::mem::offset_of!(RsPointObj, x) as i64);
+    assert_eq!(
+        label.flags,
+        (FieldFlags::WRITABLE | FieldFlags::SEQ_HASH_IGNORE).bits()
+    );
+    let metadata = unsafe { &*type_info::<RsPointObj>().metadata };
+    assert_eq!(
+        metadata.total_size as usize,
+        std::mem::size_of::<RsPointObj>()
+    );
+    assert_eq!(
+        metadata.structural_eq_hash_kind,
+        TVMFFISEqHashKind::kTVMFFISEqHashKindTreeNode as i32
+    );
+
+    // A subtype registers its own fields, private ones too, and inherits the 
kind.
+    assert_eq!(field_names::<RsPoint3Obj>(), ["z", "tags", "tag"]);
+    let kind = |info: &TVMFFITypeInfo| unsafe { 
(*info.metadata).structural_eq_hash_kind };
+    assert_eq!(
+        kind(type_info::<RsPoint3Obj>()),
+        TVMFFISEqHashKind::kTVMFFISEqHashKindTreeNode as i32
+    );
+    // One without fields registers its metadata all the same.
+    assert!(field_names::<RsMarkerObj>().is_empty());
+    assert_eq!(
+        kind(type_info::<RsMarkerObj>()),
+        TVMFFISEqHashKind::kTVMFFISEqHashKindTreeNode as i32
+    );
+}
+
+#[test]
+fn test_constructors_deref_and_upcast() {
+    let p3 = point3(1, 3);
+    // Through `RsPoint3Obj` to `RsPointObj`.
+    assert_eq!((p3.x, p3.label.as_str()), (1, "p3"));
+    assert_eq!((p3.z, p3.tags.len(), p3.tag), (3, 2, 7));
+
+    let point3_obj: &RsPoint3Obj = &p3;
+    let point_obj: &RsPointObj = point3_obj;
+    assert_eq!(point_obj.x, 1);
+
+    let upcast: RsPoint = p3.clone().into();
+    assert!(upcast.same_as(&p3));
+    assert_eq!(upcast.x, 1);
+    let back = RsPoint3::try_from(Any::from(upcast)).unwrap();
+    assert!(back.same_as(&p3));
+    assert!(RsPoint3::try_from(Any::from(point(1, ""))).is_err());
+
+    let marker = 
RsMarker::from(RsMarkerObj::new(RsPointObj::new(Object::new(), 4, "m")));
+    assert_eq!(marker.x, 4);
+    assert!(RsPoint::from(&marker).same_as(&marker));
+
+    // A root type converts into `ObjectRef`, its parent type.
+    let root = point(2, "r");
+    assert!(ObjectRef::from(&root).same_as(&root));
+}
+
+#[test]
+fn test_new_takes_literals_of_the_field_types() {
+    let scalars = RsScalars::from(RsScalarsObj::new(Object::new(), 
5_000_000_000, 1, 2, 0.5));
+    assert_eq!(
+        (scalars.big, scalars.count, scalars.port, scalars.ratio),
+        (5_000_000_000, 1, 2, 0.5)
+    );
+    assert_eq!(
+        field_names::<RsScalarsObj>(),
+        ["big", "count", "port", "ratio"]
+    );
+}
+
+#[test]
+fn test_cpp_structural_equality_and_deep_copy_see_the_fields() {
+    // `label` is ignored.
+    assert!(structural_equal(&point(1, "a"), &point(1, "b")));
+    assert!(!structural_equal(&point(1, "a"), &point(2, "a")));
+    assert!(structural_equal(&point3(1, 2), &point3(1, 2)));
+    assert!(!structural_equal(&point3(1, 2), &point3(0, 2)));
+
+    let original = point3(5, 6);
+    let copy = deep_copy(&original);
+    assert!(!copy.same_as(&original));
+    assert_eq!((copy.x, copy.label.as_str()), (5, "p3"));
+    assert_eq!((copy.z, copy.tags.len(), copy.tag), (6, 2, 7));
+
+    let repr = tvm_ffi::String::try_from(call(
+        "ffi.ReprPrint",
+        &[AnyView::from(&Any::from(original))],
+    ))
+    .unwrap();
+    assert!(repr.as_str().contains("z=6"), "{}", repr.as_str());
+}
+
+#[test]
+fn test_shallow_copy_and_finality_are_type_attributes() {
+    let attr = |type_index, name| get_type_attr(type_index, name).unwrap();
+    let is_final = |type_index| bool::try_from(attr(type_index, 
"__ffi_type_final__")).unwrap();
+    assert!(!is_final(RsPointObj::type_index()));
+    assert!(is_final(RsPoint3Obj::type_index()));
+    assert!(is_final(RsMarkerObj::type_index()));
+
+    let original = 
RsMarker::from(RsMarkerObj::new(RsPointObj::new(Object::new(), 3, "m")));
+    let shallow_copy =
+        Function::try_from(attr(RsMarkerObj::type_index(), 
"__ffi_shallow_copy__")).unwrap();
+    let copy = 
RsMarker::try_from(shallow_copy.call_tuple((original.clone(),)).unwrap()).unwrap();
+    assert!(!copy.same_as(&original));
+    assert_eq!((copy.x, copy.label.as_str()), (3, "m"));
+
+    // C++ cannot construct the type from its fields.
+    assert!(Function::from_type_key_method("testing.rust.RsPoint", 
"__ffi_init__").is_err());
+}
+
+#[test]
+fn test_subtype_of_a_cpp_type() {
+    let child = cxx_child(7, 8, "c");
+    assert_eq!((child.v_i64, child.v_i32, child.name.as_str()), (7, 8, "c"));
+    // The C++ fields are reflected under the subtype.
+    let v_i64 = FieldGetter::new(RsCxxChildObj::type_index(), 
"v_i64").unwrap();
+    assert_eq!(v_i64.get::<_, i64>(&*child.base).unwrap(), 7);
+    assert_eq!(field_names::<RsCxxChildObj>(), ["name"]);
+
+    let base = TestCxxClassBase::from(child.clone());
+    assert_eq!(base.v_i32, 8);
+
+    assert!(structural_equal(&child, &cxx_child(7, 8, "c")));
+    assert!(!structural_equal(&child, &cxx_child(7, 9, "c")));
+    assert!(!structural_equal(&child, &cxx_child(7, 8, "d")));
+
+    let copy = deep_copy(&child);
+    assert!(!copy.same_as(&child));
+    assert_eq!((copy.v_i64, copy.v_i32, copy.name.as_str()), (7, 8, "c"));
+}
+
+#[test]
+fn test_crate_path_names_a_reexported_tvm_ffi() {
+    let value = 
reexported::Reexported::from(reexported::ReexportedObj::new(Object::new(), 5));
+    assert_eq!(value.v, 5);
+    assert!(ObjectRef::from(&value).same_as(&value));
+    assert_eq!(field_names::<reexported::ReexportedObj>(), ["v"]);
+}
diff --git a/tests/python/test_stubgen_rust.py 
b/tests/python/test_stubgen_rust.py
index 56d6a75f..5d4c9a97 100644
--- a/tests/python/test_stubgen_rust.py
+++ b/tests/python/test_stubgen_rust.py
@@ -730,7 +730,7 @@ impl Deref for Expr {
 }
 
 impl ExprObj {
-    pub(crate) fn new(span: Option<Span>, ty: Type) -> Self {
+    pub fn new(span: Option<Span>, ty: Type) -> Self {
         let base = Object::new();
         Self { base, span, ty }
     }
@@ -738,10 +738,20 @@ impl ExprObj {
 
 impl Expr {
     /// Lossless complete-field allocation.
-    pub fn new(span: Option<Span>, ty: Type) -> Self {
-        let obj = ExprObj::new(span, ty);
+    pub fn new(span: impl Into<Option<Span>>, ty: impl Into<Type>) -> Self {
+        let obj = ExprObj::new(span.into(), ty.into());
         Self { base: ObjectArc::new(obj) }
     }
+}
+
+impl ShallowCopy for ExprObj {
+    fn shallow_copy(&self) -> Self {
+        Self {
+            base: ShallowCopy::shallow_copy(&self.base),
+            span: Clone::clone(&self.span),
+            ty: Clone::clone(&self.ty),
+        }
+    }
 }"""
 
 ADD_EXPECTED = """\
@@ -790,8 +800,13 @@ impl AddObj {
 
 impl Add {
     /// Lossless complete-field allocation.
-    pub fn new(span: Option<Span>, ty: PrimType, a: PrimExpr, b: PrimExpr) -> 
Self {
-        let obj = AddObj::new(span, ty, a, b);
+    pub fn new(
+        span: impl Into<Option<Span>>,
+        ty: impl Into<PrimType>,
+        a: impl Into<PrimExpr>,
+        b: impl Into<PrimExpr>,
+    ) -> Self {
+        let obj = AddObj::new(span.into(), ty.into(), a.into(), b.into());
         Self { base: ObjectArc::new(obj) }
     }
 }
@@ -805,15 +820,33 @@ def test_render_complete_expr_golden() -> None:
     RUST.add_directive(imports, "nullable", "ir.Expr.span", 1)
     text, imports = _render(_expr(), imports)
     assert text == EXPR_EXPECTED
-    assert _uses(imports) == {"std::ops::Deref", "tvm_ffi::Object", 
"tvm_ffi::ObjectArc"}
+    assert _uses(imports) == {
+        "std::ops::Deref",
+        "tvm_ffi::Object",
+        "tvm_ffi::ObjectArc",
+        "tvm_ffi::ShallowCopy",
+    }
+
+
+def test_render_shallow_copy_that_fits_is_one_line() -> None:
+    """A `ShallowCopy` within 100 columns is on one line, as rustfmt keeps 
it."""
+    text, _ = _render(_info("ir.Stmt", (_field("span", "ir.Span", 24, 8),), 
total_size=32))
+    assert (
+        "impl ShallowCopy for StmtObj {\n"
+        "    fn shallow_copy(&self) -> Self {\n"
+        "        Self { base: ShallowCopy::shallow_copy(&self.base), "
+        "span: Clone::clone(&self.span) }\n"
+        "    }\n"
+        "}"
+    ) in text
 
 
 def test_render_complete_add_golden() -> None:
     """`tirx.Add` as tvm-rust-ext hand-writes it, on top of a complete 
`ir.Expr`.
 
     `field` directives narrow `a` / `b` and the inherited allocator parameter
-    `ty` (upcast with `.into()` on the way to `ExprObj::new`); `upcast` adds
-    the `PrimExpr` view.
+    `ty` (upcast with `.into()` on the way to `ExprObj::new`, which takes 
`Type`
+    as it is); `upcast` adds the `PrimExpr` view.
     """
     _register(_expr())
     imports = RUST.new_imports()
@@ -898,7 +931,7 @@ def test_render_complete_enum_field() -> None:
     RUST.add_directive(imports, "enum", "demo.Pair.kind -> Kind(i32) { A=0, 
B=1 }", 1)
     text, imports = _render(info, imports)
     assert "    pub kind: Kind,\n" in text
-    assert "    pub fn new(a: i64, kind: Kind) -> Self {" in text
+    assert "    pub fn new(a: i64, kind: impl Into<Kind>) -> Self {" in text
     assert {"tvm_ffi::Result", "tvm_ffi::Error", "tvm_ffi::VALUE_ERROR"} <= 
_uses(imports)
     assert "tvm_ffi::FieldGetter" not in _uses(imports)
 
@@ -960,7 +993,10 @@ def test_complete_big_int_field_mirrors() -> None:
         "    pub count: i64,\n"
         "}"
     ) in text
-    assert "    pub fn new(value: BigInt, maybe: Optional<BigInt>, count: i64) 
-> Self {" in text
+    assert (
+        "    pub fn new(value: impl Into<BigInt>, maybe: impl 
Into<Optional<BigInt>>, count: i64)"
+        " -> Self {"
+    ) in text
     assert "assert!(::core::mem::size_of::<BigObj>() == 64);" in text
     assert {"tvm_ffi::BigInt", "tvm_ffi::Optional"} <= _uses(imports)
 
@@ -1071,8 +1107,13 @@ def test_custom_new_renames_the_wrapper_allocator() -> 
None:
     )
     assert (
         "impl Add {\n    /// Lossless complete-field allocation.\n"
-        "    pub fn from_fields(span: Span, ty: Type, a: Expr, b: Expr) -> 
Self {\n"
-        "        let obj = AddObj::new(span, ty, a, b);\n"
+        "    pub fn from_fields(\n"
+        "        span: impl Into<Span>,\n"
+        "        ty: impl Into<Type>,\n"
+        "        a: impl Into<Expr>,\n"
+        "        b: impl Into<Expr>,\n"
+        "    ) -> Self {\n"
+        "        let obj = AddObj::new(span.into(), ty.into(), a.into(), 
b.into());\n"
         "        Self { base: ObjectArc::new(obj) }\n"
         "    }\n}"
     ) in text
@@ -1243,7 +1284,7 @@ def test_registry_complete_chain_is_mirrored() -> None:
     # The allocator flattens the chain; a signature over 100 columns wraps.
     assert (
         "impl TestCxxClassDerivedDerivedObj {\n"
-        "    pub(crate) fn new(\n"
+        "    pub fn new(\n"
         "        v_i64: i64,\n"
         "        v_i32: i32,\n"
         "        v_f64: f64,\n"
@@ -1256,11 +1297,31 @@ def test_registry_complete_chain_is_mirrored() -> None:
         "    }\n"
         "}"
     ) in dd
+    # The wrapper's `new` takes a field that is not a scalar from anything 
that converts into it.
+    assert (
+        "        v_str: impl Into<String>,\n"
+        "        v_bool: bool,\n"
+        "    ) -> Self {\n"
+        "        let obj =\n"
+        "            TestCxxClassDerivedDerivedObj::new(v_i64, v_i32, v_f64, 
v_f32, v_str.into(), v_bool);\n"
+    ) in dd
+    # Not final, under generated ancestors: a Rust subtype can copy its 
parent's object.
+    assert (
+        "impl ShallowCopy for TestCxxClassDerivedDerivedObj {\n"
+        "    fn shallow_copy(&self) -> Self {\n"
+        "        Self {\n"
+        "            base: ShallowCopy::shallow_copy(&self.base),\n"
+        "            v_str: Clone::clone(&self.v_str),\n"
+        "            v_bool: Clone::clone(&self.v_bool),\n"
+        "        }\n"
+        "    }\n"
+        "}"
+    ) in dd
     assert dd.endswith(
         "tvm_ffi::impl_object_upcast!(TestCxxClassDerivedDerived => 
TestCxxClassBase, "
         "TestCxxClassDerivedDerived => TestCxxClassDerived);"
     )
-    assert "tvm_ffi::String" in _uses(imports)
+    assert {"tvm_ffi::String", "tvm_ffi::ShallowCopy"} <= _uses(imports)
 
 
 def test_registry_hidden_field_and_vptr_stay_opaque() -> None:
@@ -1515,6 +1576,44 @@ def test_ty_map_parent_redirects_base_deref_and_upcast() 
-> None:
     assert not any(path.startswith("super::") for path in _uses(imports))
 
 
+def test_allocator_hands_a_ty_map_parent_its_own_types() -> None:
+    """`<Leaf>Obj::new` takes each field as its own type, as a hand-written 
parent's `new` does.
+
+    A narrowed inherited scalar upcasts with `.into()`, whose target the 
parent's
+    `i32` names; only the wrapper's `new` takes `impl Into`.
+    """
+    _register(
+        _info(
+            "demo.Base",
+            (_field("name", "str", 24, 16), _field("kind", "int", 40, 4)),
+            total_size=48,
+        )
+    )
+    info = _info(
+        "demo.Child",
+        (_field("n", "int", 48, 8),),
+        parent="demo.Base",
+        total_size=56,
+        is_final=True,
+    )
+    ty_map = {**RUST.default_ty_map(), "demo.Base": "crate::hand::Base"}
+    imports = RUST.new_imports()
+    RUST.add_directive(imports, "field", "demo.Child.kind -> Kind", 1)
+    block = _object_block("demo.Child")
+    generate_rust_object(block, ty_map, imports, Options(), info, 
_declared("demo.Child"))
+    text = "\n".join(block.lines[1:-1])
+    assert (
+        "impl ChildObj {\n"
+        "    pub(crate) fn new(name: String, kind: Kind, n: i64) -> Self {\n"
+        "        let base = BaseObj::new(name, kind.into());\n"
+        "        Self { base, n }\n"
+    ) in text
+    assert (
+        "    pub fn new(name: impl Into<String>, kind: impl Into<Kind>, n: 
i64) -> Self {\n"
+        "        let obj = ChildObj::new(name.into(), kind.into(), n);\n"
+    ) in text
+
+
 def test_stage_3_checks_dependencies_across_the_run(tmp_path: Path) -> None:
     root = _crate(tmp_path)
     src = root / "mod.rs"
@@ -1542,6 +1641,9 @@ def 
test_stage_3_checks_dependencies_across_the_run(tmp_path: Path) -> None:
     text = "\n".join(line for block in info.code_blocks for line in 
block.lines)
     assert "    base: TestCxxClassBaseObj," in text
     assert "use crate::hand::TestCxxClassBaseObj;" in text
+    # Not final: public `new`; a hand-written parent may lack `ShallowCopy`, 
so the type has none.
+    assert "impl TestCxxClassDerivedObj {\n    pub fn new(" in text
+    assert "ShallowCopy" not in text
 
 
 # ---------------------------------------------------------------------------

Reply via email to