Skip to content

Commit ca621b3

Browse files
authored
Merge pull request #999 from godot-rust/doc/builtin-api-design
Document builtin API design
2 parents 4a49529 + 0ca511e commit ca621b3

File tree

1 file changed

+133
-25
lines changed

1 file changed

+133
-25
lines changed

godot-core/src/builtin/mod.rs

Lines changed: 133 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,139 @@
77

88
//! Built-in types like `Vector2`, `GString` and `Variant`.
99
//!
10-
//! # Background on the design of vector algebra types
11-
//!
12-
//! The basic vector algebra types like `Vector2`, `Matrix4` and `Quaternion` are re-implemented
13-
//! here, with an API similar to that in the Godot engine itself. There are other approaches, but
14-
//! they all have their disadvantages:
15-
//!
16-
//! - We could invoke API methods from the engine. The implementations could be generated, but it
17-
//! is slower and prevents inlining.
18-
//!
19-
//! - We could re-export types from an existing vector algebra crate, like `glam`. This removes the
20-
//! duplication, but it would create a strong dependency on a volatile API outside our control.
21-
//! The `gdnative` crate started out this way, using types from `euclid`, but [found it
22-
//! impractical](https://github.com/godot-rust/gdnative/issues/594#issue-705061720). Moreover,
23-
//! the API would not match Godot's own, which would make porting from GDScript (slightly)
24-
//! harder.
25-
//!
26-
//! - We could opaquely wrap types from an existing vector algebra crate. This protects users of
27-
//! `gdextension` from changes in the wrapped crate. However, direct field access using `.x`,
28-
//! `.y`, `.z` is no longer possible. Instead of `v.y += a;` you would have to write
29-
//! `v.set_y(v.get_y() + a);`. (A `union` could be used to add these fields in the public API,
30-
//! but would make every field access unsafe, which is also not great.)
31-
//!
32-
//! - We could re-export types from the [`mint`](https://crates.io/crates/mint) crate, which was
33-
//! explicitly designed to solve this problem. However, it falls short because [operator
34-
//! overloading would become impossible](https://github.com/kvark/mint/issues/75).
10+
//! Please read the [book chapter](https://godot-rust.github.io/book/godot-api/builtins.html) about builtin types.
11+
//!
12+
//! # API design
13+
//!
14+
//! Our goal is to strive for a middle ground between idiomatic Rust and existing Godot APIs, achieving a decent balance between ergonomics,
15+
//! correctness and performance. We leverage Rust's type system (such as `Option<T>` or `enum`) where it helps expressivity.
16+
//!
17+
//! We have been using a few guiding principles. Those apply to builtins in particular, but some are relevant in other modules, too.
18+
//!
19+
//! ## 1. `Copy` for value types
20+
//!
21+
//! _Value types_ are types with public fields and no hidden state. This includes all geometric types, colors and RIDs.
22+
//!
23+
//! All value types implement the `Copy` trait and thus have no custom `Drop` impl.
24+
//!
25+
//! ## 2. By-value (`self`) vs. by-reference (`&self`) receivers
26+
//!
27+
//! Most `Copy` builtins use by-value receivers. The exception are matrix-like types (e.g., `Basis`, `Transform2D`, `Transform3D`, `Projection`),
28+
//! whose methods operate on `&self` instead. This is close to how the underlying `glam` library handles it.
29+
//!
30+
//! ## 3. `Default` trait only when the default value is common and useful
31+
//!
32+
//! `Default` is deliberately not implemented for every type. Rationale:
33+
//! - For some types, the default representation (as per Godot) does not constitute a useful value. This goes against Rust's [`Default`] docs,
34+
//! which explicitly mention "A trait for giving a type a _useful_ default value". For example, `Plane()` in GDScript creates a degenerate
35+
//! plane which cannot participate in geometric operations.
36+
//! - Not providing `Default` makes users double-check if the value they want is indeed what they intended. While it seems convenient, not
37+
//! having implicit default or "null" values is a design choice of Rust, avoiding the Billion Dollar Mistake. In many situations, `Option` or
38+
//! [`OnReady`][crate::obj::OnReady] is a better alternative.
39+
//! - For cases where the Godot default is truly desired, we provide an `invalid()` constructor, e.g. `Callable::invalid()` or `Plane::invalid()`.
40+
//! This makes it explicit that you're constructing a value that first has to be modified before becoming useful. When used in class fields,
41+
//! `#[init(val = ...)]` can help you initialize such values.
42+
//! - Outside builtins, we do not implement `Gd::default()` for manually managed types, as this makes it very easy to overlook initialization
43+
//! (e.g. in `#[derive(Default)]`) and leak memory. A `Gd::new_alloc()` is very explicit.
44+
//!
45+
//! ## 4. Prefer explicit conversions over `From` trait
46+
//!
47+
//! `From` is quite popular in Rust, but unlike traits such as `Debug`, the convenience of `From` can come at a cost. Like every feature, adding
48+
//! an `impl From` needs to be justified -- not the other way around: there doesn't need to be a particular reason why it's _not_ added. But
49+
//! there are in fact some trade-offs to consider:
50+
//!
51+
//! 1. `From` next to named conversion methods/constructors adds another way to do things. While it's sometimes good to have choice, multiple
52+
//! ways to achieve the same has downsides: users wonder if a subtle difference exists, or if all options are in fact identical.
53+
//! It's unclear which one is the "preferred" option. Recognizing other people's code becomes harder, because there tend to be dialects.
54+
//! 2. It's often a purely stylistic choice, without functional benefits. Someone may want to write `(1, 2).into()` instead of
55+
//! `Vector2::new(1, 2)`. This is not strong enough of a reason -- if brevity is of concern, a function `vec2(1, 2)` does the job better.
56+
//! 3. `From` is less explicit than a named conversion function. If you see `string.to_variant()` or `color.to_hsv()`, you immediately
57+
//! know the target type. `string.into()` and `color.into()` lose that aspect. Even with `(1, 2).into()`, you'd first have to check whether
58+
//! `From` is only converting the tuple, or if it _also_ provides an `i32`-to-`f32` cast, thus resulting in `Vector2` instead of `Vector2i`.
59+
//! This problem doesn't exist with named constructor functions.
60+
//! 4. The `From` trait doesn't play nicely with type inference. If you write `let v = string.to_variant()`, rustc can infer the type of `v`
61+
//! based on the right-hand expression alone. With `.into()`, you need follow-up code to determine the type, which may or may not work.
62+
//! Temporarily commenting out such non-local code breaks the declaration line, too. To make matters worse, turbofish `.into::<Type>()` isn't
63+
//! possible either.
64+
//! 5. Rust itself [requires](https://doc.rust-lang.org/std/convert/trait.From.html#when-to-implement-from) that `From` conversions are
65+
//! infallible, lossless, value-preserving and obvious. This rules out a lot of scenarios such as `Basis::to_quaternion()` (which only maintains
66+
//! the rotation part, not scale) or `Color::try_to_hsv()` (which is fallible and lossy).
67+
//!
68+
//! One main reason to support `From` is to allow generic programming, in particular `impl Into<T>` parameters. This is also the reason
69+
//! why the string types have historically implemented the trait. But this became less relevant with the advent of
70+
//! [`AsArg<T>`][crate::meta::AsArg] taking that role, and thus may change in the future.
71+
//!
72+
//! ## 5. `Option` for fallible operations
73+
//!
74+
//! GDScript often uses degenerate types and custom null states to express that an operation isn't successful. This isn't always consistent:
75+
//! - [`Rect2::intersection()`] returns an empty rectangle (i.e. you need to check its size).
76+
//! - [`Plane::intersects_ray()`] returns a `Variant` which is NIL in case of no intersection. While this is a better way to deal with it,
77+
//! it's not immediately obvious that the result is a point (`Vector2`), and comes with extra marshaling overhead.
78+
//!
79+
//! Rust uses `Option` in such cases, making the error state explicit and preventing that the result is accidentally interpreted as valid.
80+
//!
81+
//! [`Rect2::intersection()`]: https://docs.godotengine.org/en/stable/classes/class_rect2.html#class-rect2-method-intersection
82+
//! [`Plane::intersects_ray()`]: https://docs.godotengine.org/en/stable/classes/class_plane.html#class-plane-method-intersects-ray
83+
//!
84+
//! ## 6. Public fields and soft invariants
85+
//!
86+
//! Some geometric types are subject to "soft invariants". These invariants are not enforced at all times but are essential for certain
87+
//! operations. For example, bounding boxes must have non-negative volume for operations like intersection or containment checks. Planes
88+
//! must have a non-zero normal vector.
89+
//!
90+
//! We cannot make them hard invariants (no invalid value may ever exist), because that would disallow the convenient public fields, and
91+
//! it would also mean every value coming over the FFI boundary (e.g. an `#[export]` field set in UI) would constantly need to be validated
92+
//! and reset to a different "sane" value.
93+
//!
94+
//! For **geometric operations**, Godot often doesn't specify the behavior if values are degenerate, which can propagate bugs that then lead
95+
//! to follow-up problems. godot-rust instead provides best-effort validations _during an operation_, which cause panics if such invalid states
96+
//! are detected (at least in Debug mode). Consult the docs of a concrete type to see its guarantees.
97+
//!
98+
//! ## 7. RIIR for some, but not all builtins
99+
//!
100+
//! Builtins use varying degrees of Rust vs. engine code for their implementations. This may change over time and is generally an implementation
101+
//! detail.
102+
//!
103+
//! - 100% Rust, often supported by the `glam` library:
104+
//! - all vector types (`Vector2`, `Vector2i`, `Vector3`, `Vector3i`, `Vector4`, `Vector4i`)
105+
//! - all bounding boxes (`Rect2`, `Rect2i`, `Aabb`)
106+
//! - 2D/3D matrices (`Basis`, `Transform2D`, `Transform3D`)
107+
//! - `Plane`
108+
//! - `Rid` (just an integer)
109+
//! - Partial Rust: `Color`, `Quaternion`, `Projection`
110+
//! - Only Godot FFI: all others (containers, strings, callables, variant, ...)
111+
//!
112+
//! The rationale here is that operations which are absolutely ubiquitous in game development, such as vector/matrix operations, benefit
113+
//! a lot from being directly implemented in Rust. This avoids FFI calls, which aren't necessarily slow, but remove a lot of optimization
114+
//! potential for rustc/LLVM.
115+
//!
116+
//! Other types, that are used less in bulk and less often in performance-critical paths (e.g. `Projection`), partially fall back to Godot APIs.
117+
//! Some operations are reasonably complex to implement in Rust, and we're not a math library, nor do we want to depend on one besides `glam`.
118+
//! An ever-increasing maintenance burden for geometry re-implementations is also detrimental.
119+
//!
120+
//! TLDR: it's a trade-off between performance, maintenance effort and correctness -- the current combination of `glam` and Godot seems to be a
121+
//! relatively well-working sweet spot.
122+
//!
123+
//! ## 8. `glam` types are not exposed in public API
124+
//!
125+
//! While Godot and `glam` share common operations, there are also lots of differences and Godot specific APIs.
126+
//! As a result, godot-rust defines its own vector and matrix types, making `glam` an implementation details.
127+
//!
128+
//! Alternatives considered:
129+
//!
130+
//! 1. Re-export types of an existing vector algebra crate (like `glam`).
131+
//! The `gdnative` crate started out this way, using types from `euclid`, but [became impractical](https://github.com/godot-rust/gdnative/issues/594#issue-705061720).
132+
//! Even with extension traits, there would be lots of compromises, where existing and Godot APIs differ slightly.
133+
//!
134+
//! Furthermore, it would create a strong dependency on a volatile API outside our control. `glam` had 9 SemVer-breaking versions over the
135+
//! timespan of two years (2022-2024). While it's often easy to migrate and the changes notably improve the library, this would mean that any
136+
//! breaking change would also become breaking for godot-rust, requiring a SemVer bump. By abstracting this, we can have our own timeline.
137+
//!
138+
//! 2. We could opaquely wrap types, i.e. `Vector2` would contain a private `glam::Vec2`. This would prevent direct field access, which is
139+
//! _extremely_ inconvenient for vectors. And it would still require us to redefine the front-end of the entire API.
140+
//!
141+
//! Eventually, we might add support for [`mint`](https://crates.io/crates/mint) to allow conversions to other linear algebra libraries in the
142+
//! ecosystem. (Note that `mint` intentionally offers no math operations, see e.g. [mint#75](https://github.com/kvark/mint/issues/75)).
35143
36144
// Re-export macros.
37145
pub use crate::{array, dict, real, reals, varray};

0 commit comments

Comments
 (0)