The Risk Effect: Reducing the Verbosity of Unsafe Naming Convention
1. The Problem: The Burden of Naming-Based Safety
Mojo uses the unsafe_ prefix in function and method names (e.g., Pointer.unsafe_offset) to track unsafe behavior. This keeps risk visible and searchable, ensuring developers acknowledge the danger, make a conscious decision to use it, and manually verify safety.
While effective at highlighting risk, this approach introduces several challenges:
1.1 Loss of Ergonomics and Standard Operators
Mojo pointer operations are verbose compared to C, C++, and Rust. The strict requirement to spell out unsafe_ for basic pointer arithmetic prevents using standard, ergonomic operators (like + and -).
Consider an efficient algorithm requiring raw pointers: rotating an array by k elements in O(N) time and O(1) space using the classic reversal algorithm (similar to std::rotate). Since we manipulate non-trivial types (with RAII semantics) in place, we must carefully manage memory states.
The core of this algorithm is a reverse function. Let’s compare implementations:
C++:
template <typename T>
void reverse(T* start, T* end) {
while (start < end) {
// Swap elements and advance pointers
T tmp = std::move(*start);
*start = std::move(*end);
*end = std::move(tmp);
++start;
--end;
}
}
template <typename T>
void rotate(T* ptr, size_t n, size_t k) {
k = k % n;
reverse(ptr, ptr + k - 1);
reverse(ptr + k, ptr + n - 1);
reverse(ptr, ptr + n - 1);
}
Mojo (Current state):
def reverse[T: Movable](var start: Pointer[T], var end: Pointer[T]):
while start < end:
# Swap elements
var tmp = start.unsafe_take_pointee()
start.unsafe_write_move_from(end)
end.unsafe_write(tmp^)
# Advance pointers
start = start.unsafe_offset(1)
end = end.unsafe_offset(-1)
def rotate[T: Movable](ptr: Pointer[T], n: Int, var k: Int):
k = k % n
reverse(ptr, ptr.unsafe_offset(k - 1))
reverse(ptr.unsafe_offset(k), ptr.unsafe_offset(n - 1))
reverse(ptr, ptr.unsafe_offset(n - 1))
While explicit about unsafety, the Mojo code obscures core swapping and pointer arithmetic under repeated unsafe_ prefixes. Rotating a buffer requires nine explicit unsafe operations. Burying algorithmic intent under safety boilerplate makes pure logic bugs harder to spot.
1.2 The Conflict Between Semantics and Risk
Semantic meaning and safety are distinct concerns. It is often easier to reason about them separately: first validating the core algorithm logic (the “happy path”), then checking safety constraints.
Currently, identifiers serve two distinct jobs: describing the semantic action (“what it does”) and warning of risk (“what could go wrong”). Since we cannot satisfy both without excessively long names, we compromise on API consistency.
By encoding risk directly into the identifier, we create inconsistent names for semantically identical operations. For example, Optional.unwrap() and OwnedPointer.take() both extract an inner value but cannot share a semantic name without obscuring their differing risk profiles.
Sacrificing semantic consistency for explicit safety warnings causes several usability problems:
- Obscured Algorithmic Intent: Using varied, risk-adjusted vocabulary to convey identical semantics visually clutters and obscures the core logic of an algorithm.
- Mental Overhead of Translation: When reading or writing code, developers must continuously map these risk-adjusted names back and forth to their neutral semantics.
- Fragmented API Discovery: Developers cannot intuitively rely on a single, universal concept name (e.g.,
taketo extract an inner value); they must search documentation for the specific, risk-adjusted name for each type. - Steeper Learning Curve: Inconsistent naming makes the standard library harder to learn, developers need to memorize multiple different names for the exact same semantic operation.
1.3 Inexact Safety Granularity
To prevent unmanageable verbosity, the unsafe_ prefix remains generic. It indicates caution is needed but fails to specify what risky behavior occurs.
A single prefix groups together different UB risks:
Pointer.unsafe_offset: Out of bounds (OOB) riskPointer.unsafe_deinit: Leaving memory uninitialized (UMemOut) riskPointer.unsafe_write: Assuming existing memory is uninitialized (UMemIn) riskPointer.unsafe_write_move_from: BothUMemInandUMemOutrisksArray.unsafe_get: Out of bounds (OOB) risk
While unsafe_ signals caution, the API name lacks the exact constraints required for validation.
1.4 Grepping is Insufficient for Safety (Bugs of Omission)
Grepping for unsafe_ helps audit existing code by highlighting the presence of unsafe operations. However, it completely fails for bugs caused by the absence of an unsafe operation (like a missing deinitialization), which never appear in a text search.
Note: The proposed Risk effect does not inherently solve these bugs of omission either. The point is to emphasize that sacrificing readability for extreme verbosity still fails to prevent these critical safety issues, making the readability sacrifice harder to justify.
struct CustomVector[T: AnyType]:
var _ptr: Pointer[Self.T]
var _len: Int
var _capacity: Int
def __init__(out self, capacity: Int):
# Unsafe keyword usage is far away in __init__
self._ptr = alloc(Layout[Self.T](count=capacity)).unsafe_leak()
self._len = 0
self._capacity = capacity
...
def clear(mut self):
# BUG: We reset the length, but forget to destroy the elements!
# This leaks memory, but a grep for `unsafe_` misses this
# method entirely because the unsafe call was omitted.
# Missing: unsafe_destroy_n(self._ptr, self._len)
self._len = 0
(Note: Methods with both safe and unsafe variants naturally need distinct naming; Array.unsafe_get may be acceptable. This proposal focuses on methods that solely possess unsafe versions or exhibit multiple distinct unsafe behaviors where the unsafe_ prefix heavily impacts code density.)
2. Proposal: The Risk Effect
We propose reducing the strict reliance on API naming conventions for tracking safety by introducing an orthogonal language feature dedicated to just tracking risk: The Risk Effect.
Conceptually similar to how raises tracks errors, risk tracks safety risks. Just as a function can declare def() raises IOError, it can also declare def() risk Abort or def() risk OOB. risk is an effect attached to a function indicating danger, forcing the caller to pay attention.
This separation restores standard pointer arithmetic (like + and -), regaining readability and brevity, while allowing the compiler to rigorously track and enforce safety boundaries.
(Note: The unsafe_ prefix may still be used if it provides the best semantic name for an API.)
(Note: A function can declare both error and risk effects simultaneously, e.g., def foo() raises ValueError risk OOB:)
2.1 Core Concept: Declaring Risk
A Pointer struct can define safety constraints directly in its signature without complicating names:
struct Pointer[T: AnyType]:
# ...
def __add__(self, offset: Int) risk OOB -> Self: ...
def __sub__(self, offset: Int) risk OOB -> Self: ...
def take(self) risk UMemOut -> T: ...
def write(self, value: T) risk UMemIn: ...
def write_move_from(self, src: Self) risk UMemIn|UMemOut: ... # Union of risks
2.2 Risk Handling and Propagating
When calling a risky function, the compiler enforces that the caller must either propagate the risk or explicitly handle it.
This strict enforcement remains visible in code reviews, retaining the benefits of explicit risk tracking and searchability.
1. Explicit Propagation
If a function cannot guarantee safety, it must declare the risk in its own signature to warn callers.
def advance_ptr(ptr: Pointer[Int], offset: Int) risk OOB -> Pointer[Int]:
# The `+` operator carries an "OOB" risk. We declare `risk OOB`
# in our signature to explicitly propagate it.
return ptr + offset
2. Explicit Handling (Acknowledging Risk Locally)
If a function validates constraints and ensures safety, it can handle the risk locally.
(Note: Below are potential ergonomic ways to handle risks locally. Not all are necessary for full feature support.)
A. Single-statement @risk(...) decorator:
For quick, single-line acknowledgements. Here we silence the general UB risk category because loop bounds guarantee safety.
from std.memory.alloc import alloc, dealloc, Layout
def main():
var length = 64
@risk(UMemOut)
var mem = alloc[Int](Layout(count=length))
var ptr = mem.ptr()
for i in range(length):
@risk(UB)
(ptr + i).write(i * 2)
@risk(UMemIn)
dealloc(mem^)
B. Block-level or Function-wide silencing (using risk):
When a function or block validates constraints up-front, it can safely perform risky operations without leaking risk. using risk(...) silences broad categories of risk for the remaining lexical scope without adding indentation.
def reverse[T: Movable](mut span: Span[T, mut]):
# Span is already guaranteed initialized and in-bounds.
# Handle risks locally for the rest of the scope.
using risk(MEM|OOB)
var start = span.ptr()
var end = start + len(span) - 1
while start < end:
var tmp = start.take()
start.write_move_from(end)
end.write(tmp^)
start += 1
end -= 1
C. The with risk(...) block:
Similar to Rust’s unsafe {} block, this creates a new lexical scope to acknowledge risk. Useful when multiple risky operations share the same safety validation.
def safe_swap(ptr: Pointer[Int], i: Int, j: Int, length: Int) risk Abort:
if not in_bounds(i, length) or not in_bounds(j, length):
abort("Index out of bounds")
# Bounds validated. Handle OOB and MEM risks locally.
with risk(OOB | MEM):
var ptr_i = ptr + i
var ptr_j = ptr + j
var tmp = ptr_i.take()
ptr_i.write_move_from(ptr_j)
ptr_j.write(tmp^)
3. Compiler Enforcement
If a developer forgets to handle or propagate a risk, the compiler throws an error, ensuring safety constraints are not accidentally overlooked.
def advance_ptr(ptr: Pointer[Int], offset: Int) -> Pointer[Int]:
# COMPILER ERROR: Unhandled risk `OOB` from `+` operator.
# Declare `risk OOB` in signature or handle locally.
return ptr + offset
2.3 Granularity and The Risk Graph
Like Errors, Risks should form a hierarchy. We can define broad categories and specific sub-risks:
UB- Undefined BehaviorOOB- Out of BoundsMEM- Memory RisksUMemIn- Accepting uninitialized memoryUMemOut- Returning uninitialized memoryLeak- Potentialy leaking memory
ThreadingDataRace
Abort- Intentional program terminationSecurityRawPassword
Tooling uses this defined risk hierarchy to allow precise querying. For example, when auditing for undefined behavior bugs, developers can query for all UB risks, automatically surfacing sub-risks like OOB and UMemIn.
Why not a simple boolean unsafe like Rust?
The Benefits of Granularity:
Fine-grained types separate fundamentally different dangers:
- Context-Dependent Hazards: In Rust,
abortis safe because it prevents undefined behavior, meaning a potential abort can be buried deep indirectly in the call stack without warning. However, for many applications—such as kernel code, automotive controllers, satellite systems, or NASA rovers—anabortis a critical system failure. Modeling it explicitly allows these domains to surface and strictly trackrisk Abortacross the call graph, easily finding and reviewing all places that claim to handle it. - Memory vs. Threading: A data race is different from out-of-bounds memory access.
- Domain-Specific Risk: Elegantly extends to security risk modeling (e.g.,
risk SqlInjectionorrisk RawPassword).
Typeless Risk (The Opt-Out Mechanism):
For users not needing fine-grained tracking, the system gracefully degrades to a boolean model. Just as Mojo supports raises without a specific error type, it supports risk without a specific risk type.
An untyped risk acts as the “top type”—it declares or absorbs any risk.
def foo() risk:is equivalent to Rust’sunsafe fn.with risk:is equivalent to Rust’sunsafe {}block.
This provides extreme precision for critical systems and standard boolean unsafe ergonomics for everyday development.
(Note: Because the concrete Error hierarchy feature is currently missing in Mojo, we are not defining the exact design of the Risk structure here. Conceptually, Risks should form a tree or acyclic graph-like hierarchy open to expansion from 3rd party libraries, similar to Python Exceptions. The concrete design can be expanded upon if the Modular team expresses interest in the broader Risk effect.)
2.4 Advanced Mechanics
1. Parametric Risk and the Never Risk
What if a function’s safety depends on a comptime parameter?
Mojo’s raises design elegantly unifies raising and non-raising functions. We can apply this exact pattern to unify risky and safe functions.
By treating risk Never as strictly equivalent to a completely safe function (one that omits the risk keyword), we can support parametric risk:
def get[bounds_check: Bool = True](self, idx: Int) risk (Never if bounds_check else OOB) -> Self.T:
comptime if bounds_check:
assert self.is_in_bounds(idx)
return self.ptr[idx]
When bounds_check=True, the function has risk Never. When False, it carries OOB risk.
Reducing Verbosity with Bitwise Operations
For multiple parameters affecting risk, nested if-else expressions become verbose. Allowing bitwise operators (~, &, |) to compose risks like bitmasks could reduce verbosity. Treating False as Never (absence of risk, or 0), we could use & as a mask and | to union risks:
def get_element[
bounds_check: Bool = True,
sync: Bool = True
](self, idx: Int) risk ((~bounds_check & OOB) | (~sync & Unsync)) -> Self.T:
...
This syntax directly evaluates the final risk:
- Both Safe: If both flags are
True, the expression evaluates toFalse | False, which isFalse(risk Never). - Mixed Safety: If
bounds_checkisFalsebutsyncisTrue, the expression evaluates toOOB | False, leaving only theOOBrisk.
(Alternatively, we could support standard boolean operators directly, e.g., (not bounds_check and OOB) or (not sync and Unsync)).
2. Higher-Order Risk Propagation
Similarly, we can propagate risks abstractly in higher-order functions, just like parametric raises:
def apply[R: Risk](f: def() thin risk R) risk R:
f()
Explicitly supporting both parametric errors and risks can make signatures complex:
def apply[E: Error, R: Risk](f: def() thin raises E risk R) raises E risk R:
f()
Auto-parameterization simplifies signatures by extracting effect types directly from the passed function:
def apply(f: def() thin raises _ risk _) raises RaisesType[f] risk RiskType[f]:
f()
2.5 The Impact: Developer Experience & Tooling
Moving risk information into effects restores standard arithmetic operators and intuitive naming.
Restoring Ergonomics and Standard Operators
Reduces visual noise and focuses developers on core logic. Let’s revisit rotate:
def reverse[T: Movable](var start: Pointer[T], var end: Pointer[T]) risk UB:
# see section 2.2 for local risk handling
while start < end:
# Swap elements clearly and concisely
var tmp = start.take()
start.write_move_from(end)
end.write(tmp^)
# Advance pointers using standard operators
start += 1
end -= 1
def rotate[T: Movable](ptr: Pointer[T], n: Int, var k: Int) risk UB:
k = k % n
reverse(ptr, ptr + k - 1)
reverse(ptr + k, ptr + n - 1)
reverse(ptr, ptr + n - 1)
The algorithm is now as readable as C++, yet retains exact safety tracking.
Consistent Semantic Naming
By shifting the primary burden of safety tracking to the risk effect system, we can unify semantically equivalent operations across multiple types. For example, the semantic operation of taking the inner value from a container type can be consistently named:
Pointer.take()OwnedPointer.take()Optional^.take()Variant^.take()
This makes the language more approachable:
- Clearer Algorithmic Intent: Standardized vocabulary prevents safety warnings from visually cluttering and obscuring the core logic of an algorithm.
- Zero Translation Overhead: Developers no longer suffer the continuous mental overhead of mapping risk-adjusted names back and forth to their neutral semantics when reading or writing code.
- Universal API Discovery: Developers can intuitively rely on a single, universal concept name (e.g.,
take) instead of searching documentation for type-specific, risk-adjusted variants. - Flatter Learning Curve: Consistent naming makes the standard library significantly easier to learn, as developers no longer need to memorize multiple different names for the exact same semantic operation.
Fast Parsing and IDE Integration
Because risk is embedded in the signature, the compiler and LSP don’t need to parse and elaborate the function body to determine safety constraints.
Editors can display inline hints (e.g., via ctrl+alt in VSCode) to reveal risks without cluttering source code:
# Normal view (clean semantics):
var count = ptr1 - (ptr2 + 8)
# With inlay hints toggled ON:
var count = ptr1 risk - (ptr2 risk + 8)
# With inlay hints toggled ON with reasons:
var count = ptr1 risk(OOB) - (ptr2 risk(OOB) + 8)
Hovering over operations reveals context. This keeps compilation fast and developer feedback instant.
3. Alternatives Considered
1. Function Decorators (@risk)
Using a simple function decorator (@risk(OOB)) is fast to parse but entirely static. It cannot elegantly express parametric risk (where risk depends on comptime parameters) or handle higher-order risk propagation. The effect system natively supports both.
2. Risk Expressions in the Function Body
Using standalone risk expressions inside the function body conditionally tags specific code paths:
def my_func[bounds_check: Bool = True](self, idx: Int):
comptime if bounds_check:
assert self.is_in_bounds(idx)
else:
risk(OOB) # Conditionally emit risk
return self.ptr[idx]
While inferring risk from the body makes signatures cleaner, it impacts tooling performance and risks accidental propagation. The compiler and LSP must parse and elaborate the function body to infer the risk profile. Furthermore, implicit inference can accidentally leak internal risks to the caller, which would require adding another explicit mechanism to control propagation (e.g., @risk(propagate=UB)). We focus on signature-level effects to ensure safety boundaries can be analyzed quickly without checking the body and to keep propagation strictly explicit. However, if these trade-offs are deemed acceptable, inferring risk from body expressions is an alternative that can be explored further.
I would greatly appreciate your feedback on the problem described here, the proposed semantics, the design, syntax, and the overall direction of this idea.