Linear Types: Parameterized Deinitialization
1. The Problem: The API Duplication Burden
Mojo has introduced elegant solutions to unify common language dualities—for example, raises Never unifies raising and non-raising functions, and the ref convention unifies immutable and mutable references.
However, supporting linear types in generic collections (List, Dict, Set) currently requires library authors to duplicate significant portions of API and memory management logic. This results in maintaining standard Deinitable methods (e.g., clear, __deinit__) alongside identical linear type variants (e.g., clear_with, deinit_with).
Semantically (ignoring performance), both versions perform the exact same underlying operations. For example:
struct List[T: AnyType]:
# 1. Standard Deinitable version
def clear(mut self) where conforms_to(Self.T, Deinitable):
unsafe_destroy_n(self.data, count=len(self))
...
# 2. Linear Type version (Duplicated API)
def clear_with(mut self, deinit_func: Some[def(var T)]):
for i in len(self):
Pointer(to=self.data[i]).unsafe_deinit_with(deinit_func)
...
# Proof of equivalence: We could implement `clear` using `clear_with`
def clear(mut self) where conforms_to(Self.T, Deinitable):
self.clear_with(Self.T.__deinit__)
(Note: We are not arguing against specialized APIs designed specifically to make linear types more ergonomic. We are simply addressing the duplication of API methods that share identical underlying semantics.)
Library code handles memory management uniformly across both Deinitable and linear types—the container’s internal cleanup logic remains identical in both cases. The requirement for flexibility really exists in user code, where linear types require an explicit choice of deinitialization function per call site. Currently, bridging this gap requires library authors to duplicate API methods.
1.1 Challenges with Existing Workarounds
While we could theoretically avoid duplication using existing language features, doing so in practice introduces a fair amount of friction:
Workaround A: Implement standard methods via _with variants
If we try to implement __deinit__ by simply calling deinit_with(Self.T.__deinit__), we face three main challenges:
-
Loss of Trivial Deinit Specialization (Crucial for Performance!): Trivial type optimization (e.g., bulk destroying elements using
unsafe_destroy_n) is executed by the standard library’s user code, not the compiler. If standard deinitialization routes through a generic function pointer path (deinit_with) to accommodate linear types, the stdlib can no longer specialize and optimize for trivial types. -
Loss of ASAP Deinitialization: The compiler cannot automatically invoke a custom
deinit_funcwhen an error is raised. Developers must manually wrap raising calls intry/exceptblocks, explicitly deinitialize the value, and re-raise. This adds significant boilerplate to control flow:# Standard Version: Compiler handles cleanup automatically on raise def process_item(mut self) raises where conforms_to(Self.T, Deinitable): var item = self.extract_item() self.might_raise(item) # Compiler calls T.__deinit__(item) if this raises self.finish(item) # Linear Version: Manual cleanup required on all exit paths def process_item_with(mut self, deinit_func: Some[def(var T)]) raises: var item = self.extract_item() try: self.might_raise(item) except e: deinit_func(item) # Forced to manually catch, deinit, and re-raise raise e self.finish(item) deinit_func(item) -
Boilerplate and Delegation: To avoid duplicating core logic, standard methods must delegate to their
_withvariants. Repeating this delegation pattern across the entire API adds considerable boilerplate for library authors.
Workaround B: Wrap linear types in Deinitable structs
Developers could wrap a linear type to conform to Deinitable in order to use standard collection methods. However, this locks the type into a fixed deinit function, whereas real-world code often requires dynamic deinitialization strategies.
For example, when inserting a linear FileHandle into a Dict[String, FileHandle], we currently use the specialized insert method to apply different behaviors flexibly:
# Location A: we expect no elements to be replaced, so we abort on conflict
var displaced = my_dict.insert(key, file_handle^)
if displaced:
abort("unreachable")
# Location B: we might want to move the old value to another place
var displaced2 = my_dict.insert(key, file_handle^)
if displaced2:
move_to_other_place(displaced2.unwrap().value^)
# Location C: we might just destroy the old element
var displaced3 = my_dict.insert(key, file_handle^)
if displaced3:
displaced3.unwrap()^.close()
If we instead wrapped FileHandle in a Deinitable struct to use standard collection methods (like __setitem__), we would be locked into a single __deinit__ implementation. We couldn’t dynamically choose between abort, move_to_other_place, or FileHandle.close across different call sites.
1.2 The Scaling Cost: Combinatorial Explosion
Relying on explicit _with methods can also lead to a combinatorial explosion for collections with multiple generic types that store their elements independently.
Consider a generic collection MultiStore[A, B] that stores A and B independently, where each has its own special handling for trivially-deinitable types. If a user wants to provide a custom deinit for just B but not A, the library author faces a difficult choice:
- To retain performance for the trivially-deinitable types while supporting custom linear types, they must write an exponential number of method overloads (in this case, 4 distinct overloads to cover all combinations of default and custom deinits for
AandB). - Alternatively, they could provide a single unified method (e.g.,
clear_with(..., a_deinit, b_deinit)). However, using the unified method forces the trivially-deinitable type (A) down an unoptimized function pointer path just to accommodateB, compounding the performance issues described above.
2. A Potential Semantic Direction
To bridge this gap without duplicating APIs, it might be beneficial to explore built-in language support.
Rather than implementing __deinit__ using deinit_with, we could invert the paradigm: algorithms are written once using the ergonomics of ASAP deinit, and the deinitialization function is parameterized per call site.
Under this model, all types can be Deinitable if they provide a deinit function:
- Implicitly deinitable types automatically provide
T.__deinit__as the default deinit function. - Linear types would need to provide their custom deinit function per call site whenever an algorithm requires them to be
Deinitable.
This means library authors can simply write algorithms for Deinitable types without worrying about what specific deinit function is used. They can write the algorithm with the ergonomics of ASAP deinit, and allow users to use it with linear types by providing an explicit deinit version per call site.
Specializing at the call site is quite important: the parametric function is specialized per call site according to the user-provided T.deinit, whether that is the default T.__deinit__ (retaining all trivial type optimizations) or a user-provided explicit function.
This approach preserves the strict linear nature of linear types, as they still require explicit lifecycle management from the user. The goal is simply to provide feature parity between implicit and explicit deinitialization in library implementations where the underlying semantics are identical. By doing so, we could gracefully solve both the combinatorial explosion and the loss of optimization.
3. Design and Syntax Ideas
Important Note: The exact syntax and design presented below represent a minimal direction, not a finalized specification. The primary goal of this proposal is to establish agreement on the problem (Section 1) and the proposed semantics (Section 2). If the Modular team agrees that this semantic direction is valuable, we would love to collaboratively iterate on the design and syntax challenges.
To make these semantics concrete, here is one potential approach using traits:
trait Deinitable:
"""
Allows for normal ASAP deinitialization.
Supports Linear types when an explicit deinit function is provided at the callsite.
"""
comptime deinit: Some[def(var Self)]
comptime __deinit_is_trivial__: Bool
trait ImplicitlyDeinitable(Deinitable):
"""
The deinit function implicitly used by the compiler for ASAP deinit.
Can be overridden at callsites with alternative user-provided functions.
"""
comptime deinit = Self.__deinit__
def __deinit__(deinit self, /): ...
By default, all types would be ImplicitlyDeinitable. Linear types would opt out (e.g., struct Linear(ImplicitlyDeinitable where False)).
Functions parameterized on Deinitable could gain a special optional runtime keyword argument T.deinit = .... This allows developers to override the default deinit function (T.__deinit__) with a user-provided one at the call site. The type of the function would be inferred from the runtime value passed to this keyword argument. Using the type name prefix (T.deinit) helps avoid parameter name conflicts with standard function arguments.
3.1 API Consolidation: Eliminating Duplicated Methods
The primary benefit of this design is that we no longer need to write duplicated APIs (like lst.clear_with(custom_deinit) vs lst.clear()). By implementing a single regular method dependent on the Deinitable trait, the API automatically supports custom deinitialization.
This approach cleanly eliminates duplication across generic functions and collections:
Standalone Functions:
def process_item[T: Deinitable](arg: T): ...
# Call site overrides the deinit behavior
process_item(arg, T.deinit=custom_deinit)
Collection Methods:
# Uses default T.__deinit__
my_set.clear()
# Replaces all internal T.__deinit__ calls with my_deinit
my_set.clear(T.deinit=my_deinit)
# Avoids parameter shadowing via explicit path
my_set.clear(my_set.T.deinit=my_deinit)
Direct Struct Deinitialization:
Structs can also use deinit(T.deinit=...) directly as a replacement for the deinit_with API:
# Destroys the list, explicitly providing a custom deinitializer for its elements
lst^.deinit(T.deinit=custom_deinit)
3.2 Optional Overrides for Non-Linear Types
This mechanism is not exclusively for linear types. Any function parameterized on Deinitable could have its deinitialization behavior overridden. The core difference is that while a linear type must provide an explicit deinit function at the call site to use these APIs, an ImplicitlyDeinitable type has the option.
For non-linear types, developers can rely on the default implicit deinitialization, or choose to provide an explicit override. This can be useful if you want to change the lifecycle of standard elements—for example, moving an element somewhere else instead of destroying it.
3.3 Restoring Standard APIs for Linear Types
Because explicit deinitializers could be attached at the call site, linear types would regain access to standard APIs. For example, Dict.__setitem__ currently cannot be used with linear types, which necessitated the creation of a specialized Dict.insert. While Dict.insert is a great method and we are not suggesting removing it, if a collection lacked such specialized methods we would no longer be blocked from using linear types until an equivalent was implemented.
We could rewrite the complex FileHandle insertion examples shown earlier directly using __setitem__, maintaining exact parity while removing the boilerplate:
# Location A: abort on conflict
my_dict[key, V.deinit=lambda v: abort("unreachable")] = file_handle^
# Location B: move old value
my_dict[key, V.deinit=lambda v: move_to_other_place(v^)] = file_handle^
# Location C: destroy old element
my_dict[key, V.deinit=FileHandle.close] = file_handle^
This would automatically extend linear support to other methods without duplicated code:
d.pop(key, K.deinit=my_custom_key_deinit)
3.4 Solving the Combinatorial Explosion
It would also solve the combinatorics explosion by allowing selective overrides, gracefully retaining trivial optimizations for unspecified types:
# Modifies only the value's deinit, retaining trivial optimization for the key
my_dict.clear(V.deinit=custom_value_deinit)
3.5 Design Challenges
I acknowledge there is a conflation of using the deinit function as a compile-time parameter, since it can also be provided as a runtime kwarg. This might require exploring parametric traits or expressing it via some other syntax.
(Note: This design idea can be naturally extended to other traits beyond deinitialization. For instance, when calling a method parameterized on the Copyable trait, a user could provide T.copy=my_deep_copy at the specific call site to seamlessly swap the default copy behavior with a custom deep copy implementation.)
I would greatly appreciate your feedback on the problem described here, the proposed semantics, the design, syntax, and the overall direction of this idea.