[Proposal] The Risk Effect: Reducing the Verbosity of Unsafe Naming Convention

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., take to 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) risk
  • Pointer.unsafe_deinit: Leaving memory uninitialized (UMemOut) risk
  • Pointer.unsafe_write: Assuming existing memory is uninitialized (UMemIn) risk
  • Pointer.unsafe_write_move_from: Both UMemIn and UMemOut risks
  • Array.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 Behavior
    • OOB - Out of Bounds
    • MEM - Memory Risks
      • UMemIn - Accepting uninitialized memory
      • UMemOut - Returning uninitialized memory
      • Leak - Potentialy leaking memory
    • Threading
      • DataRace
  • Abort - Intentional program termination
  • Security
    • RawPassword

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, abort is 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—an abort is a critical system failure. Modeling it explicitly allows these domains to surface and strictly track risk Abort across 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 SqlInjection or risk 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’s unsafe fn.
  • with risk: is equivalent to Rust’s unsafe {} 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 to False | False, which is False (risk Never).
  • Mixed Safety: If bounds_check is False but sync is True, the expression evaluates to OOB | False, leaving only the OOB risk.

(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.

Your proposal is quite interesting, but it unclear to me how one “handles” an OOB risk. We generally want people to write safe logic in terms of unsafe code. We want it to be clear when unsafe operations are performed, but we aren’t trying to encourage it - we’re trying to discourage it.

FYI, your rotate is incorrect for odd # elements I think. :slight_smile:

-Chris

Thanks for the feedback and taking the time to read the proposal! I completely agree that our ultimate goal is to encourage developers to write safe logic in terms of unsafe code, and to make unsafe operations highly visible.

I’d like to clarify how the Risk effect aligns with that exact philosophy:

1. How one “handles” an OOB risk
In this proposal, “handling” a risk means validating the constraint that makes the operation safe. This strictly follows Mojo’s existing definition of safety:

  • Array.__getitem__ performs a bounds check. Because it handles the OOB risk internally, it exposes a safe API to the caller.
  • Array.unsafe_get skips the bounds check, exposing the OOB risk. The caller is now responsible for handling it (e.g., by ensuring their loop bounds are correct).

The Risk effect doesn’t change what is unsafe; it simply changes how we track it. Instead of baking the danger into the name (unsafe_get), we track it in the type system (risk OOB), but the requirement to validate the safety constraint (like doing a bounds check) remains exactly the same.

2. Discouraging unsafe operations
Mojo currently discourages unsafe operations by making them highly visible and verbose (forcing the unsafe_ prefix).

This proposal also discourages unsafe operations through explicit verbosity. To use a risky operation, the developer cannot just call it silently; they must explicitly decorate the call site (e.g., @risk(UB)) or wrap it in a block (with risk(UB):). This requires a conscious, visible decision that stands out in code review. It preserves the exact same friction that discourages casual use, but keeps the semantic names of the methods clean.

ptr = ptr.unsafe_offset(5)

@risk(OOB)
ptr += 5

with risk(OOB):
    ptr += 5

3. The rotate example and readability
The primary purpose of the rotate example was to demonstrate how line-by-line verbosity (unsafe_ prefixes on every pointer addition/subtraction) can obscure the core logic of a dense algorithm, making pure logic bugs harder to spot in code review.

By introducing a with risk(UB): block (conceptually similar to Rust’s unsafe {} blocks), the developer is still forced to explicitly acknowledge the danger in a highly visible way. However, the core swapping and pointer arithmetic inside the block become much easier to read, allowing reviewers to focus on verifying the algorithmic logic rather than parsing through a sea of unsafe_ prefixes.

(Rotate logic: it uses the classic 3-reversal algorithm from Jon Bentley’s Programming Pearls. I didn’t verify it).

I basically have two problems with this proposal:

  1. While this effect looks pretty in simple examples, I suspect it’ll get a lot noisier when combined with other effects. Specifically, I’m thinking of situation where both the abi, raises and this potential risk effect is spelled out (could be rare now but who knows what’ll happen in the future). Or situations where it is used with a bare raise effect - def some_func() raises risk(OOB) - it would seem like the risk is the error being raised; clarity here would require language convention guide documents.

  2. While functions with risk effect could be searched, the search doesn’t directly lead to specific lines with the risk like you would get if you searched for unsafe_ at the moment.

Edit: On a second read I see that there are proposed mechanisms that could be sufficient for the second part (@risk decorator, using risk keywords). However, the entire thing introduces another problem, core language syntax and semantic changes are being proposed to replace what is basically a simple api naming convention, and it is not immediately clear to me that it is better than the simple api naming.

I really appreciate this discussion, and both Chris and Melody raise crucial points—especially around the goal of encapsulation and the heavy cost of introducing full language-level syntax changes to replace what is currently a library naming convention.

However, if we look at bridging the gap, a fine-grained, block/scope-level approach could address the core pain points while avoiding the syntax noise Melody mentioned at the function signature level:

  1. Avoiding Signature Pollution & Effect Stacking: Melody rightly points out that stacking abi, raises, and risk in function signatures creates massive visual noise and potential syntactic confusion (e.g., def foo() raises risk(OOB) looking like a raised error type). Moving fine-grained risk handling down into tight lexical blocks or local line decorators (like with risk(OOB) or @risk(OOB)) keeps signatures clean while still allowing standard, readable operator logic (+, -) right where the pointer arithmetic happens.

  2. Precision Auditability vs. Blanket Trust: In standard Rust-style unsafe {} blocks, once you enter the block, all safety checks are turned off. A block meant only to unlock raw pointer addition also silently permits uninitialized memory writes or dangling pointer dereferences. Fine-grained block-level constraints let the compiler enforce all other safety invariants while relaxing only the specific constraint you’ve manually verified.

  3. Line-Level Searchability: Melody’s concern about grep finding exact lines (rather than just signature declarations) is crucial for real-world security audits. Scope/block-level keywords or local decorators (with risk(...), @risk(...)) preserve exact line-by-line searchability just like unsafe_ currently does—you get direct grep hits right where the risk occurs.

  4. Self-Documenting Review Intent: When auditing low-level code, seeing a localized risk(OOB) block tells reviewers the exact invariant the author verified. A broad unsafe_ prefix or blanket unsafe block forces a reviewer to re-derive every possible vector of Undefined Behavior from scratch.

If the goal is writing safe, low-level abstractions, giving developers fine-grained, localized tools to scoped-silence specific risks feels like a stronger safety model than an all-or-nothing approach—without needing to clutter top-level function signatures.