Hi, I would like to ask for your opinions on an idea about introducing a runtime reflection mechanism:
Summary
The proposed feature is an opt-in runtime reflection mechanism allowing inspection of type information along with its properties.
Motivation
Rust currently doesn't support any runtime reflection mechanism, apart from TypeId
unique identifier. This makes it impossible to implement patterns like Dependency Injection which rely solely on runtime type information. The only way to work around such problems is compile-time use of attributes, which is not always possible and results in tight coupling between the affected types and attribute crates.
By allowing to opt-in to using runtime reflection, we enable a whole class of high-level patterns and ease development by removing the burden of adding compile-time workarounds with a possible benefit of reducing build times.
Guide-level explanation
By enabling feature-gated runtime reflection, the programmer can inspect type information without the hassle of using attributes/macros. For example, let's consider a model entity which we would like to map to a database:
pub struct Entity {
pub id: number,
pub name: String,
}
One way of making the struct persistable in the DB is to manually write functions which have the knowledge on how to map each field to a DB column. This approach has some disadvantages:
- Requires manual work.
- Requires manually keeping the logic up-to-date with the model.
- Doesn't scale - the amount of work required gets multiplied by each type we want to support.
Another possibility is to use attributes/macros - this is the way the diesel crate currently operates. The code needs to persist the entity is generated at compile time by the use of macros representing DB structure, and attributes on the entity types themselves. This approach also has problems:
- Necessitates the use of macros, which either has to be done by hand or by using a dedicated cli.
- Attributes add coupling between the model and the specific DB crate.
Finally, we can leverage runtime reflection to extract all the necessary information from the type itself to dynamically create the required database mapping. This solves the problems of manual work (none needed), keeping the code up to date (happens automatically at runtime) and coupling with external crates. Also, we get automatic scaling with increasing number of types involved without scaling up the build time. Consider a simple example:
fn persist<T>(entity: &T) -> Result<(), String> {
// get the information about the type
// the actual API will be discussed later in the reference documentation
let reflection = std::reflect::get_type_info(entity).expect("Cannot get type info!");
match *reflection {
std::reflect::Type::Struct(info) => {
// get field information
match info {
// iterate over struct fields and save them
std::reflect::StructType::Named(struct_info) => for field in struct_info.fields {
match field.type.expect("Cannot get field type!") {
std::reflect::Type::I8 => {
let value: &i8 = field.as_field_ref(entity).expect("Cannot get field value!");
// call imaginary persist_field() method with our concrete field value and its name to a table having the name of the struct
persist_field(value, field.name, reflection.get_name())?;
},
// handle rest of the possible types
...
}
},
_ => return Err("Only named fields are supported!"),
}
},
_ => Err("You can only persist structs!"),
}
}
By using runtime reflection, we instantly add support for persisting our entities without any changes in the crate itself, thus achieving clear separation of concerns. We no longer need to use macros or do any maintenance work relating to changes in data.
Reference-level explanation
Proposed API (everything is assumed to live in the std::reflect namespace):
enum Type {
Bool,
I8,
U8,
I16,
U16,
I32,
U32,
I64,
U64,
I128,
U128,
ISize,
USize,
F32,
F64,
Char,
Struct(StructType),
Enum(EnumType),
Tuple(TupleType),
Array(ArrayType),
Union(UnionType),
Fn(FnType),
Closure(ClosureType),
Slice(SliceType),
Ref(RefType),
ConstPtr(PtrType),
MutPtr(PtrType),
Never,
Unit,
}
impl Type {
/// Returns the name of the type. The naming rules are the same as for `std::any::type_name`.
const fn get_name(&self) -> &'static str;
}
The Type
enum represents the basic block of information which can be further inspected to get type details. This information should be statically available and provided by the compiler. Retrieval is done by using:
const fn get_type_info<T>(subject: &T) -> Option<&'static Type>;
The function accepts a reference to a subject of any possible type, returning optional type information. Given reflection is an opt-in feature, to avoid adding code and data when not needed, Option
is required. Also, returned information is assumed to be an immutable reference to static memory stored in an unspecified (from the user perspective) location. Concrete type-level details can be examined further. Note: the function should retrieve information for concrete implementation of dyn
and impl Trait
types, thus making it generic at runtime.
struct StructType {
Named(NamedStruct),
Unnamed(UnnamedStruct),
Unit,
}
struct NamedStruct {
fields: &'static [FieldNamed],
}
struct UnnamedStruct {
fields: &'static [FieldUnnamed],
}
Basic information about a structure and its contents.
struct FieldNamed {
name: &'static str,
type: Option<&'static Type>,
}
impl FieldNamed {
/// Returns an immutable reference to the field, given the containing struct reference, or None if the field is inaccessible, nonexistent or has a wrong type.
const fn as_field_ref<T, S>(parent: &S) -> Option<&T>;
/// Returns a mutable reference to the field, given the containing struct reference, or None if the field is inaccessible, nonexistent or has a wrong type.
const fn as_field_mut<T, S>(parent: &mut S) -> Option<&mut T>;
}
struct FieldUnnamed {
type: Option<&'static Type>,
}
impl FieldUnnamed {
/// Returns an immutable reference to the field, given the containing struct reference, or None if the field is inaccessible, nonexistent or has a wrong type.
const fn as_field_ref<T, S>(parent: &S) -> Option<&T>;
/// Returns a mutable reference to the field, given the containing struct reference, or None if the field is inaccessible, nonexistent or has a wrong type.
const fn as_field_mut<T, S>(parent: &mut S) -> Option<&mut T>;
}
struct FieldNamedUnsafe {
name: &'static str,
type: Option<&'static Type>,
}
impl FieldNamedUnsafe {
/// Returns an immutable reference to the field, given the containing struct reference, or None if the field is inaccessible, nonexistent or has a wrong type.
unsafe const fn as_field_ref<T, S>(parent: &S) -> Option<&T>;
/// Returns a mutable reference to the field, given the containing struct reference, or None if the field is inaccessible, nonexistent or has a wrong type.
unsafe const fn as_field_mut<T, S>(parent: &mut S) -> Option<&mut T>;
}
Information about struct fields for named and unnamed variants. Note: the structs might contain hidden members enabling the necessary functionality.
struct EnumType {
type: Option<&'static Type>,
variants: &'static [EnumVariant],
}
struct EnumVariant {
name: &'static str,
type: Option<&'static Type>,
}
struct TupleType {
fields: &'static [FieldUnnamed],
}
struct ArrayType {
element_type: Option<&'static Type>,
length: usize,
}
struct UnionType {
fields: &'static [FieldNamedUnsafe],
}
struct FnType {
parameters: &'static [ParamType],
result: Option<&'static Type>,
is_const: bool,
is_unsafe: bool,
}
struct ParamType {
type: Option<&'static Type>,
}
struct ClosureType {
parameters: &'static [ParamType],
result: Option<&'static Type>,
}
struct SliceType {
element_type: Option<&'static Type>,
}
struct RefType {
underlying_type: Option<&'static Type>,
is_mut: bool,
}
struct PtrType {
underlying_type: Option<&'static Type>,
}
Information above other corresponding types.
All of the above types should implement Debug
and may implement Display
.
Drawbacks
Generating reflection information might take non-insignificant time and result in large binaries. That's why the feature is gated and needs to be explicitly opted in. Potential security concerns should also be taken into account.
Rationale and alternatives
Runtime reflection has proven to be instrumental in making high-level patterns possible without introducing tight coupling between components. While heavy in itself, it can provide a cleaner alternative to manually generated code or extensive use of macros and attributes. This proposal lies the foundation of the reflection mechanism, which can be extended further in the future.
Prior art
We can see similar mechanism present in non-native languages like Java or C#, where reflection is used extensively to provide various features - from simple serialization, to advanced patterns like Inversion of Control, Dependecy Injection or Data Mapper. We can see example frameworks like Hibernate, Spring or ASP.NET successfully using reflection as a tool for providing an environment where large projects can focus on domain problems rather than lower-level concerns (serialization, dependency management, domain/persistence layer separation, etc.), reducing the need of manual work.
Unresolved questions
- How to handle lifetimes? Should they be exposed?
- How to expose attributes?
- Should async fn/closure types be handled differently?
- Currently the API exposes non-public fields in structs but makes them inaccessible - it might be a better idea to skip such fields entirely. Maybe add a visibility enum?
- Const functions are currently implemented as a "minimal" version, which doesn't support all necessary features needed by this proposal. We need to wait for the full implementation to make everything const.
Future possibilities
Crates like serde, rocket or diesel can use the reflection to separate their responsibilities form user code as much as possible. By making all function const, we also enable reflection to work in constant contexts, thus enabling the possibility to shift the work to the compiler.