Mojo’s raw pointer type is unconventional: it can be either safe or unsafe, depending on the operation being performed on it. This is possible due to the semantics of Mojo’s lifetime, ownership, and memory management model. I wanted to quickly explore how all this comes together to enable a unique way of working with pointers.
Note
This is a simplified view of the pointer type and does not explore all its capabilities.
To understand this Schrödinger’s pointer, you’ll need a quick dip into Mojo’s ownership and lifetime model.
To begin, Mojo is a language that aims to be safe by default; this means the language aims to prevent common ways of misusing memory at compile time. To achieve this, it uses three language mechanisms: unique ownership, ASAP destruction, and origins.
Unique ownership enforces that every value has exactly one owner. As Soon As Possible (ASAP) destruction enforces that values are destroyed as soon as it is safe to do so - value destruction does not wait till the end of the scope.
Origins are attached to references and point back to the referenced value; they’re used to enforce two things:
Argument exclusivity: there can either be one mutable reference of the same origin or multiple immutable references.
Value lifetime: Ensure that referenced values live for as long as the references are valid
You can read about these in detail in Mojo’s documentation, but the mechanics will make sense in a bit.
Like in Zig, Pointers in Mojo are non-nullable, and unlike Zig, they can carry origin information:
def main():
var some_value = "test value"
var v_ptr: Pointer[String, origin_of(some_value)] = Pointer(to=some_value)
print(v_ptr[])
In the above snippet, the Pointer’s type can be removed; it’ll be inferred by the compiler. I added it here to show how the type has an origin that points to an existing Mojo variable. This pointer is perfectly safe by default. It is guaranteed to Point to an existing, fully initialised value that can never be null. For as long as v_ptr is in use, some_value will remain alive. The compiler will also enforce argument exclusivity and lifetime analysis on pointers using the origin information.
Here are two pointers to the same value; trying to use both at the same time results in a compile error:
def some_operation[o: MutOrigin](ptr1: Pointer[String, o], ptr2: Pointer[String, o]):
pass
def main():
var some_value = "test value"
var v_ptr = Pointer(to=some_value)
var v_ptr_2 = Pointer(to=some_value)
some_operation(v_ptr, v_ptr_2)
If you compile this, you get the following error:
pointers.mojo:8:17: error: aliasing values passed mutably to 'ptr1' argument and passed mutably to 'ptr2' argument in 'some_operation' call
some_operation(v_ptr, v_ptr_2)
^~~~~~ ~~~~~~~
/pointers.mojo:8:17: note: 'origin_of(some_value)' memory accessed through reference embedded in value of type 'Pointer[String, origin_of(some_value)]'
some_operation(v_ptr, v_ptr_2)
^
Null pointers are represented using the NoneType value, which has a niche optimisation to ensure the memory layout is the same as an actual null pointer, so you can pass None to an FFI interface. To represent that some Pointer can contain null values in Mojo itself, you wrap the Pointer in an Optional type, e.g.
var nullable: Optional[Pointer[SomeType, SomeOrigin]] = some_operation_that_returns_nullable_pointer()
This eliminates null pointer dereference and all the problems associated with it.
Mojo types try to be safe by default, including the raw pointer; however, this does not mean you can’t perform unsafe operations on them. And this brings us to the unsafe part of the Pointer type.
Since the type itself can be fully safe and fully participate in the compiler’s memory safety and lifetime analysis, safety, then, is defined by operations you can perform on the pointer type. All unsafe operations have the word “unsafe” somewhere in the api signature, usually as a prefix but sometimes as an argument. To demonstrate, the simple pointer type I defined above is fully safe by default and points to just one element; what happens if I try to dereference a second element:
def main():
var some_value = "test value"
var v_ptr = Pointer(to=some_value)
print(v_ptr[unsafe_offset=1])
The code compiles without issues, but it is actually undefined behaviour. The pointer still gives you all the control expected from a raw pointer and all the facilities to shoot yourself.
Next, let’s look at a case where the pointer is not fully safe by default: when you allocate fresh memory.
from std.memory import alloc, dealloc, Layout
@fieldwise_init
struct BasicType(Copyable, Writable):
var a: Int
def main():
var layout = Layout[BasicType](count=2)
var allocation = alloc(layout)
var basic_ptr: Pointer[BasicType, MutUntrackedOrigin] = allocation^.unsafe_leak()
print(basic_ptr[])
Here I define a very basic type, then explicitly allocate some memory to hold two elements. Mojo’s default alloc function returns an allocation handle rather than the allocated Pointer directly, so I need to call the unsafe_leak method to take ownership of the pointer, this means I opt out of the safety features of the wrapping handle and become responsible for freeing the pointer when I’m done (which I’m not doing here, but that’s fine because we’re at the end of the program anyway). This code has a big problem. But I first want to talk about the difference in the Pointer type here when compared to the previous pointer that pointed to an existing value. You can see that the origin of this new pointer is MutUntrackedOrigin; this origin tells the compiler that this pointer doesn’t reference any existing values in the Mojo type system. It is useful when you’re dynamically allocating fresh memory or interfacing with external code over FFI; I like to think of it as “there is no lifetime analysis/extension to do here”. However, it is an origin, so it still knows how to catch argument exclusivity violations. If we reintroduce the some_operation no-op function, we can have this:
from std.memory import alloc, dealloc, Layout
@fieldwise_init
struct BasicType(Copyable, Writable):
var a: Int
def some_operation[o: MutOrigin](mut ptr1: Pointer[BasicType, o], mut ptr2: Pointer[BasicType, o]):
pass
def main():
var layout = Layout[BasicType](count=2)
var allocation = alloc(layout)
var basic_ptr = allocation^.unsafe_leak()
some_operation(basic_ptr, basic_ptr)
Which compiles with this error:
pointers.mojo:14:17: error: aliasing values passed mutably to 'ptr1' argument and passed mutably to 'ptr2' argument in 'some_operation' call
some_operation(basic_ptr, basic_ptr)
^~~~~~~~~~ ~~~~~~~~~
pointers.mojo:14:17: note: 'origin_of(basic_ptr)' value is passed through aliasing 'mut' argument 'ptr2'
some_operation(basic_ptr, basic_ptr)
^ ~~~~~~~~~
This is in contrast to Rust where, in unsafe contexts, the compiler expects exclusivity but shifts the responsibility of enforcing it to the programmer.
Now, to the problem in the code. Mojo does not catch reads and writes to uninitialized memory through the pointer type; the language itself catches reads and writes to uninitialized memory in static contexts, but dynamically allocated memory is another matter. Previously, I allocated some raw memory, and I dereferenced the pointer without first initialising the memory; here is the code again:
from std.memory import alloc, dealloc, Layout
@fieldwise_init
struct BasicType(Copyable, Writable):
var a: Int
def main():
var layout = Layout[BasicType](count=2)
var allocation = alloc(layout)
var basic_ptr: Pointer[BasicType, MutUntrackedOrigin] = allocation^.unsafe_leak()
print(basic_ptr[])
This is undefined behaviour in Mojo but is not caught at compile time. When I compile and run this program, this is what I get:
BasicType(a=94036140838672)
This is the one place the language gives you complete responsibility and no help. If you allocate a raw pointer, you have to make sure you properly initialise the memory before reading it, and you have to make sure to destroy the pointee values before freeing the memory because the compiler doesn’t do lifetime analysis on UntrackedOrigin; it doesn’t know when to call destructors. Here is a more complete example (still with the memory leak):
from std.memory import alloc, dealloc, Layout
@fieldwise_init
struct BasicType(Copyable, Writable):
var a: Int
def main():
var layout = Layout[BasicType](count=2)
var allocation = alloc(layout)
var basic_ptr: Pointer[BasicType, MutUntrackedOrigin] = allocation^.unsafe_leak()
# Initialise pointee memory before reading
for i in range(layout.count()):
basic_ptr.unsafe_offset(i).unsafe_write(BasicType(i)) # initalises memory and writes to it
print(basic_ptr[unsafe_offset=i]) # Now safe to read
# Assume operations are complete, destroy pointees before freeing
for i in range(layout.count()):
basic_ptr.unsafe_offset(i).unsafe_deinit_pointee() # call the destructors
Compiling and running this outputs:
BasicType(a=0)
BasicType(a=1)
The final piece of Mojo’s pointer story I want to touch on is the allocation handles. If you observed, I said the one place Mojo gives you complete responsibility with raw pointers is ensuring you’re not reading or writing to uninitialized memory, but here I’ve been leaking memory by not freeing allocated memory; surely I do have complete responsibility there as well? Well, yes, but I do have a lot of help in that regard. Unlike most mainstream languages, Mojo supports linear types, and these have been deeply incorporated into how allocation works. Let’s revisit my first dynamic allocation snippet, but this time with a very small change: instead of taking ownership of the Pointer, I get a reference to it that I can work with.
from std.memory import alloc, dealloc, Layout
@fieldwise_init
struct BasicType(Copyable, Writable):
var a: Int
def main():
var layout = Layout[BasicType](count=2)
var allocation = alloc(layout)
var basic_ptr = allocation.unsafe_ptr() # change is here
print(basic_ptr[])
When I try compiling this code, I get the following error:
pointers.mojo:11:8: error: 'allocation' abandoned without being explicitly destroyed: An `Allocation` owns heap storage and must be consumed before it goes out of scope. Deallocate it with `dealloc(allocation^)`, or call `unsafe_leak()` to take ownership of the underlying pointer.
print(basic_ptr[])
^
The semantics at work here are that alloc by default returns an Allocation; this type owns the allocated Pointer and is linear. Linear types in Mojo are basically types that have opted out of the automatically managed ASAP destructor model. By default, the compiler will insert calls to the destructor and synthesise one if you didn’t define any. However, when a type has opted out of the ASAP destructor model, users of the api are responsible for explicitly calling a destructor, or consuming the type in a certain way. By default, all Mojo types that are initialised must be deinitialized… unless you’re dealing with raw pointers. So calling the destructor is enforced by the compiler (when it can’t do it automatically). Here is a sample snippet where I opt out of the ASAP model for BasicType:
@fieldwise_init
struct BasicType(Copyable, Writable, Deinitable where False):
var a: Int
def main():
var basic = BasicType(1)
Trying to compile shows the following error:
pointers.mojo:6:24: error: 'basic' abandoned without being explicitly destroyed: type 'BasicType' does not conform to 'Deinitable' and must be explicitly destroyed
var basic = BasicType(1)
^
Which is the same thing you see if you try to abandon an allocation (There is a slight difference in the error message because I did not supply a custom error message). Now, I’ve not provided ways to consume or deinit this BasicType, so any code that uses it in this state can not compile. When I define the exact mechanism of how I want this type consumed, I can pass a custom error message with an example of that for the compiler to show.
So, by default, Mojo’s type system will stop you from leaking allocated memory, but as I’ve demonstrated throughout, you can easily choose to take your destiny into your own hands. Additionally, when combined with the fact that Mojo’s pointers carry origin information, you also get protection against double frees and use-after-free. Here is an example: I’m trying to use a pointer after deallocation:
from std.memory import alloc, dealloc, Layout
@fieldwise_init
struct BasicType(Copyable, Writable):
var a: Int
def main():
var layout = Layout[BasicType](count=2)
var allocation = alloc(layout)
var basic_ptr = allocation.unsafe_ptr()
# ... some actions are done ...
dealloc(allocation^)
print(basic_ptr[])
When I compile this, I get the following compile error:
pointers.mojo:14:18: error: use of uninitialized value 'allocation'
print(basic_ptr[])
^
pointers.mojo:9:7: note: 'allocation' declared here
var allocation = alloc(layout)
^
The design of Mojo’s pointer fascinates me. Having a single type that can be safe or unsafe is a bit controversial, but the language did experiment with having separate safe and unsafe pointer variants for a long time. It became obvious after some time that the safe variant did not really have to exist. It wasn’t being used much and was in a weird place … wedged between references and the unsafe pointer. After some more maturity from the language and type system, and the knowledge that the unsafe variant can be made a lot safer, unifying both was an obvious decision that made the language less confusing.