IMPORTANT: To view this page as Markdown, append `.md` to the URL (e.g. /docs/manual/basics.md). For the complete Mojo documentation index, see llms.txt.
Skip to main content
Version: Nightly
For the complete Mojo documentation index, see llms.txt. Markdown versions of all pages are available by appending .md to any URL (e.g. /docs/manual/basics.md).

Register and trivial types in the lifecycle

Mojo values follow a common lifecycle, but different types participate in its stages differently. An initializer creates a value. Code may copy it, move it, or transfer it to a new owner. Finally, Mojo destroys it after its last use. Most types let you customize these stages.

Some values need almost none of that. An Int is eight bytes of integer and nothing more: no heap memory, no open file, no reference count. Copy its bits and you have a complete second value. Throw its bits away and nothing is lost. For values like these, every customizable stage is a stage that can only go wrong.

Mojo lets a type give up control of lifecycle stages it doesn't need. A register type gives up control of moving. A trivial register type gives up control of copying and destruction as well. This page follows one example of each through its lifecycle.

Values that are just their bits​

A small, self-contained value can live in a register and travel between functions without a trip through memory.

Mojo calls a type register-passable when its values work this way. Int, UInt8, Float64, Bool, SIMD vectors, and Pointer are all register-passable. String isn't: a string's bits include the address of a heap buffer that the string owns, so the string can't be just its bits.

Three lifecycles​

This page uses two example types alongside ordinary types such as String:

  • Descriptor, a register type, holds an operating-system file descriptor. Its value is one integer, but that integer names an open file that someone has to close.
  • Sample, a trivial register type, holds one audio sample. It owns nothing beyond its bits.

Here's what each kind of type lets you control:

StageOrdinary typeRegister typeTrivial register type
CreateYour initializersYour initializersYour initializers
CopyOpt in; custom copy allowedOpt in; custom copy allowedImplicit; always a bit copy
MoveCustom move allowed; can opt outAlways a bit copyAlways a bit copy
TransferEnds the originalEnds the originalCopies; the original remains
DestroyCustom, generated, or explicitCustom, generated, or explicitDoes nothing

The rest of this page walks through those rows.

Creation​

All three kinds of type create values the same way, with initializers. What differs is what the fields can hold.

Conform a struct to RegisterPassable to make it a register type:

from std.ffi import external_call

struct Descriptor(RegisterPassable):
var fd: Int32

def __init__(out self, fd: Int32):
self.fd = fd

def __deinit__(deinit self):
# The integer is small; the file it names isn't
_ = external_call["close", Int32](self.fd)

Conform a struct to TrivialRegisterPassable to make it a trivial register type:

@fieldwise_init
struct Sample(TrivialRegisterPassable, Writable):
var channel: UInt8
var value: Float32

Every field of a register type must be register-passable, and every field of a trivial register type must be trivial. A String field compiles in neither. A Sample can hold other trivial values, but not a Descriptor, because a Descriptor has cleanup to do:

@fieldwise_init
struct StereoFrame(TrivialRegisterPassable):
var left: Sample
var right: Sample

Copying​

A register type copies the way an ordinary type does. It isn't copyable until you add Copyable, and it can define its own copy initializer when copying means more than duplicating bits. For Descriptor, a copy would have to open a second descriptor for the same file.

A trivial register type always copies by duplicating its bits, and it copies implicitly. Every assignment and every argument gets its own copy, with no copy() call. You can't write its copy initializer:

def amplify(var sample: Sample, gain: Float32) -> Sample:
sample.value *= gain # changes this function's copy only
return sample

def main():
var raw = Sample(channel=2, value=0.5)
var edited = raw # implicit copy
edited.value = 0.25 # raw keeps its own bits
var louder = amplify(raw, 2.0) # amplify() gets a copy
print(raw) # Sample(channel=2, value=0.5)
print(edited) # Sample(channel=2, value=0.25)
print(louder) # Sample(channel=2, value=1.0)

The three values never affect each other.

Moving​

An ordinary type can define a custom move initializer, or opt out of moving with not Movable.

Register types and trivial register types give up both choices. Mojo moves their values by copying bits, so you can't write a move initializer, and the type can't opt out of Movable. A value with no address of its own to protect has no reason to stay in one place.

Transfer​

The transfer sigil (^) offers a value's ownership to a new owner.

For a register type, that works exactly as it does for an ordinary type. After var keeper = log_fd^, the descriptor belongs to keeper, and log_fd is finished. A file Descriptor owns an open file, so ownership has real meaning and responsibilities.

A trivial value has nothing to hand over: no buffer, no file, no cleanup. So ^ on a trivial value makes a copy, and the original stays usable:

def main():
var count = 42
var total = count^ # warning: this transfer has no effect
count += 1 # still legal; count was copied, not transferred
print(count, total) # 43 42

The compiler warns that the transfer has no effect and offers to remove the sigil. Take the offer. A ^ tells the reader the original is finished, and for a trivial value that isn't true. The same holds for your own trivial types: amplify(raw^, 2.0) copies raw exactly as amplify(raw, 2.0) does.

Save the sigil for values that own something.

Destruction​

A register value dies the way any value does: at its last use, by its deinitializer. Descriptor closes its file there. A register type can also require explicit destruction, so that forgetting to release it fails to compile:

struct Lease(
RegisterPassable, not Deinitable else "call 'release()' to return the lease"
):
var id: Int32

def __init__(out self, id: Int32):
self.id = id

def release(deinit self):
# return the lease to its pool here
pass

A trivial value also dies at its last use, but nothing runs and nothing observable changes. A trivial register type can't define a deinitializer, and it can't require explicit destruction. A value with nothing to clean up has no cleanup to forget.

Trivial lifecycles without the trait​

A struct doesn't have to declare TrivialRegisterPassable for its copies and destruction to be trivial. When Mojo generates a type's copy initializer and deinitializer, and every field is trivial, those stages are trivial too:

@fieldwise_init
struct Pixel(Copyable):
var red: UInt8
var green: UInt8
var blue: UInt8

Pixel copies and dies as plain bits, but it isn't implicitly copyable, so copies stay visible in code: var backup = pixel.copy().

Two things make a stage non-trivial. A field that isn't trivial, such as a String, makes copying and destruction non-trivial. A hand-written __deinit__() method you create does the same, making destruction non-trivial but copying stays trivial. Only a non-trivial field, like String, breaks both the copy and destruction stages.

Check a lifecycle in generic code​

Generic code sometimes depends on a value being just its bits. Use the comptime predicates in std.traits to ask about each lifecycle stage:

from std.traits import (
IsTriviallyCopyable,
IsTriviallyDeinitable,
IsTriviallyMovable,
)
  • IsTriviallyCopyable[T] is true when T is Copyable and copying a value's bits has no side effects.
  • IsTriviallyMovable[T] is true when T is Movable and moving a value's bits has no side effects. This holds for most types, including String, so copying and destruction are where types usually differ.
  • IsTriviallyDeinitable[T] is true when T is Deinitable and deinitialization is a no-op.

For example, this function appends a value's raw bytes to a buffer, the way a binary file writer would. Copying a value as bytes is only correct when copying it is trivial, so the function asserts it:

from std.sys import size_of
from std.traits import IsTriviallyCopyable

@fieldwise_init
struct Header(Copyable):
var magic: UInt32
var count: UInt32

def append_bytes[T: Copyable](mut buffer: List[Byte], value: T):
comptime assert IsTriviallyCopyable[T], "'T' must be trivially copyable"
var bytes = Pointer(to=value).unsafe_bitcast[Byte]()
for i in range(size_of[T]()):
buffer.append(bytes.unsafe_offset(i)[])

def main():
var buffer = List[Byte]()
append_bytes(buffer, Header(magic=0x4D4F4A4F, count=3))
print(len(buffer)) # 8

Header holds two integers, so it passes. A header with a String field fails at compile time with the assertion's message, because its bytes include an address that means nothing once written to a file.