Files
LLVMParty-llvm-nanobind/devdocs/api-design-philosophy.md
T

12 KiB

API Design Philosophy

This document explains the principles behind the llvm-nanobind API refactor.


Why This Refactor Matters

The earliest API mirrored the LLVM C API directly. While this made initial implementation easier, it created a non-Pythonic experience. The current API is object-oriented and discoverable:

# Current: Pythonic object-oriented API
i32 = ctx.types.i32
const = i32.constant(42)
func.attributes.add("noreturn")
inst.metadata["llvm.loop"] = loop_md

Core Principles

1. Methods Belong to Objects

Principle: Operations on an object should be methods of that object, not global functions that take the object as an argument.

Why: Python is object-oriented. When you write func.attributes.add("noreturn"), it's clear which function and which attribute slot are being modified. With llvm.add_attribute_at_index(func, ...), you must read the arguments and know LLVM's raw index convention.

C API constraint: The C language has no classes, so LLVM uses LLVMAddAttributeAtIndex(LLVMValueRef Fn, ...). Python has no such limitation.


2. Discoverability via Autocomplete

Principle: Users should be able to discover available operations through IDE autocomplete.

Why: With 93 global functions, typing llvm. is overwhelming. When operations are methods:

  • type. shows only type operations
  • func. shows only function operations
  • builder. shows only builder operations

This reduces cognitive load and helps users explore the API.


3. Logical Grouping (Cohesion)

Principle: Related functionality should live together.

Why: The C API scatters related operations:

  • LLVMConstInt, LLVMConstReal, LLVMConstNull are all global
  • LLVMAddAttributeAtIndex, LLVMGetAttributeCountAtIndex are both global

In Python, these naturally group:

  • All constant creation → methods on Type
  • All attribute operations → methods on Function/CallInst

4. Property-Based Access for Namespaces

Principle: When there's a fixed set of named items, use properties instead of factory methods.

Why: Reduces boilerplate and reveals structure:

# Types are organized under a namespace
i8 = ctx.types.i8
i16 = ctx.types.i16
i32 = ctx.types.i32
ptr = ctx.types.ptr

The ctx.types namespace makes it clear these are all type-related, and IDE autocomplete shows all available types.


5. Consistent with Python Ecosystem

Principle: Follow patterns established by popular Python libraries.

Examples:

  • numpy: array.reshape(), not np.reshape(array, shape)
  • pandas: df.groupby(), not pd.groupby(df, column)
  • pathlib: path.exists(), not os.path.exists(str(path))

Python developers expect object.operation(), not module.operation(object).


6. Global Functions Should Be Exceptional

Principle: Module-level functions should only exist when there's no natural object to attach them to.

Acceptable globals:

  • Ownerless factories: create_context()
  • Target and intrinsic registry lookups that do not mutate IR

LLVM target/disassembler registries are initialized when the module is imported; public initialization functions add no user-facing value.

Factories with a natural owner should be static methods instead (for example, BinaryManager.from_file() rather than a module-level binary factory).

Not acceptable: Any function that takes an LLVM object as its first argument.


7. Flat Hierarchy with Runtime Assertions

Principle: Keep the type system simple, but catch errors at runtime.

Why not full hierarchy: Creating dozens of Python classes (IntegerType, PointerType, PHINode, CallInst, BranchInst...) would:

  • Massively increase binding complexity
  • Make the API harder to learn
  • Create confusion about which class to use

Instead: Keep Value and Type as the main classes, but add runtime assertions:

# Instead of: isinstance(phi, llvm.PHINode) and phi.add_incoming(val, bb)
# We have:    value.add_incoming(val, bb)  # asserts value is actually a PHI

This catches errors with clear messages while keeping the API simple.


8. C API Anchors in Binding Docs

Principle: Every public binding method or property should document the LLVM-C API it uses with a <sub>C API: ...</sub> line.

Why: The C API anchor keeps the binding grounded in LLVM's actual surface, makes generated stubs searchable, and lets future maintainers or agents verify behavior against LLVM headers.

If a method is a pure convenience wrapper, list the primary LLVM-C calls it composes. If LLVM-C lacks a required operation, document that as <sub>C API limitation</sub>.


9. Method Chaining Potential

Principle: Methods returning values enable fluent APIs.

Future potential:

result = i32.constant(42).const_bitcast(ptr_ty)

With global functions, chaining is impossible.


The Problem with the Current API

Visual Comparison

Current (Pythonic):

import llvm

with llvm.create_context() as ctx:
    i32 = ctx.types.i32
    fn_ty = ctx.types.function(i32, [i32, i32])

    with ctx.create_module("test") as mod:
        fn = mod.add_function("add", fn_ty)
        lhs, rhs = fn.params
        entry = fn.append_basic_block("entry")

        with entry.create_builder() as builder:
            result = builder.add(lhs, rhs, "sum")
            builder.ret(result)

        assert mod.verify(), mod.verification_error

Properties vs Methods

Principle: Use properties for attribute-like access; use methods for operations that require arguments or have side effects.

When to Use Properties

Pattern Use Property Example
No-argument getter inst.opcode, value.type, block.terminator
Boolean check value.is_constant, func.is_declaration
Count/length inst.num_operands, phi.num_incoming
Collection access block.instructions, term.successors
Read-write attribute global.linkage, global.alignment

When to Use Methods

Pattern Use Method Example
Takes arguments inst.get_operand(index), phi.get_incoming_value(i)
Has side effects phi.add_incoming(val, bb), block.move_before(other)
Factory/creation type.constant(42), ctx.create_module(name)

Property Naming

# Good: property access reads naturally
opcode = inst.opcode
count = phi.num_incoming
ty = value.type

# Avoid: get_ prefix is redundant for properties
opcode = inst.get_instruction_opcode()  # Too verbose

Naming Conventions

Type Checking Properties: is_* not is_a_*

Principle: Use is_* for type checking properties, not is_a_*.

# Good: follows Python's isinstance() naming pattern
if value.is_function:
    ...
if value.is_constant_int:
    ...

# Avoid: the "a" is redundant
if value.is_a_function:  # Reads awkwardly
    ...

Rationale: Python uses isinstance(), not is_an_instance(). The is_* pattern is consistent with Python conventions and more concise.

Collection Properties: Plural Names

Principle: Use plural names for properties that return collections.

# Good: plural indicates iteration
for inst in block.instructions:
    ...
for succ in terminator.successors:
    ...
for handler in catchswitch.handlers:
    ...

# Avoid: singular names for collections
for inst in block.instruction_list:  # Verbose
    ...

Drop Redundant Prefixes

C API Pattern Python Property
LLVMGetInstructionOpcode() opcode
LLVMGetNumOperands() num_operands
LLVMGetAlignment() alignment
LLVMGetLinkage() linkage

The object context makes the prefix unnecessary: inst.opcode is clearly the instruction's opcode.


Iterators and Collections

Principle: Provide collection properties for common iteration patterns. Prefer returning lists over exposing manual linked-list traversal APIs.

Before (Manual Iteration)

# Tedious: manual linked-list traversal
inst = block.first_instruction
while inst is not None:
    process(inst)
    inst = inst.next_instruction

After (Collection Property)

# Pythonic: standard iteration
for inst in block.instructions:
    process(inst)

Guidelines

  1. Provide collection properties when iteration is common:

    • block.instructions - all instructions in a basic block
    • func.basic_blocks - all basic blocks in a function
    • term.successors - all successor blocks of a terminator
    • value.uses - all uses of a value
    • value.users - all users of a value
  2. Return lists, not iterators: Collection properties return list[T], not lazy iterators. This is safer and avoids common pitfalls.

  3. Avoid exposing manual iteration APIs: Do NOT expose first_* / next_* style APIs for linked-list traversal. These are footguns:

    # DANGEROUS: Iterator invalidation during mutation
    use = value.first_use
    while use is not None:
        user = use.user
        user.replace_with(new_value)  # Modifies use-def chain!
        use = use.next_use  # CRASH: use may be invalid
    

    Instead, the list-based API creates a snapshot that's safe to iterate while mutating:

    # SAFE: List is a snapshot, iteration is stable
    for user in value.users:
        user.replace_with(new_value)  # Safe: not iterating the live chain
    
  4. Exception: Keep linked-list navigation only when necessary for insertion/deletion at specific positions:

    • inst.next_instruction / inst.prev_instruction - needed for inserting before/after
    • block.next_block / block.prev_block - needed for block reordering

    These are for targeted navigation, not iteration. The pattern while x is not None: x = x.next_* should never be needed.


Nullable vs Exception-Throwing

Principle: Prefer throwing exceptions with a has_* check over returning None when None makes code awkward.

When to Return None

Return None when the absence of a value is a normal, expected case that the caller typically handles inline:

# Good: None is natural here
next_block = block.next_block  # None if last block
parent = inst.prev_instruction  # None if first instruction

When to Throw + Provide has_* Check

Throw an exception when:

  1. None would require defensive checks in most use cases
  2. The caller should explicitly opt-in to handling the missing case
# Before: None requires defensive checks everywhere
terminator = block.terminator  # Returns None if no terminator
if terminator is not None:  # Required before every use
    match terminator.opcode:
        ...

# After: throws by default, has_* for explicit check
if block.has_terminator:
    terminator = block.terminator  # Safe: guaranteed non-None
    match terminator.opcode:
        ...

Benefits:

  • Cleaner code in common cases (most blocks have terminators)
  • Explicit opt-in for edge cases
  • Catches bugs: accessing terminator on incomplete block is likely an error

Concrete exception-first examples:

  • fn.entry_block throws when fn.is_declaration is True.
  • fn.first_basic_block / fn.last_basic_block throw when fn.basic_block_count == 0.
  • fn.personality_fn, fn.prefix_data, fn.prologue_data throw when their corresponding has_* predicate is False.

Pattern Summary

Situation Approach
Missing value is common/expected Return None
Missing value is edge case Throw + has_* check
Iterator end Return None
Invalid state access Throw exception

Benefits Summary

  1. Fewer imports: No need to remember which operations are global
  2. IDE support: Autocomplete shows relevant operations
  3. Self-documenting: phi.add_incoming() vs llvm.phi_add_incoming(phi, ...)
  4. Error prevention: Methods can validate their receiver (e.g., assert it's a PHI)
  5. Consistency: All operations follow the same pattern
  6. Ergonomic iteration: Collection properties like block.instructions reduce boilerplate
  7. Cleaner null handling: has_* + throwing pattern reduces defensive checks

  • devdocs/types-api/ - Type and constant creation refactor
  • devdocs/value-api/ - Value inspection methods
  • devdocs/attribute-api/ - Function/CallInst attributes
  • devdocs/metadata-api/ - Metadata operations
  • devdocs/dibuilder-api/ - Debug info builder methods
  • devdocs/module-api/ - Module-level operations