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 operationsfunc.shows only function operationsbuilder.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,LLVMConstNullare all globalLLVMAddAttributeAtIndex,LLVMGetAttributeCountAtIndexare 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(), notnp.reshape(array, shape) - pandas:
df.groupby(), notpd.groupby(df, column) - pathlib:
path.exists(), notos.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
-
Provide collection properties when iteration is common:
block.instructions- all instructions in a basic blockfunc.basic_blocks- all basic blocks in a functionterm.successors- all successor blocks of a terminatorvalue.uses- all uses of a valuevalue.users- all users of a value
-
Return lists, not iterators: Collection properties return
list[T], not lazy iterators. This is safer and avoids common pitfalls. -
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 invalidInstead, 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 -
Exception: Keep linked-list navigation only when necessary for insertion/deletion at specific positions:
inst.next_instruction/inst.prev_instruction- needed for inserting before/afterblock.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:
- None would require defensive checks in most use cases
- 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_blockthrows whenfn.is_declarationisTrue.fn.first_basic_block/fn.last_basic_blockthrow whenfn.basic_block_count == 0.fn.personality_fn,fn.prefix_data,fn.prologue_datathrow when their correspondinghas_*predicate isFalse.
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
- Fewer imports: No need to remember which operations are global
- IDE support: Autocomplete shows relevant operations
- Self-documenting:
phi.add_incoming()vsllvm.phi_add_incoming(phi, ...) - Error prevention: Methods can validate their receiver (e.g., assert it's a PHI)
- Consistency: All operations follow the same pattern
- Ergonomic iteration: Collection properties like
block.instructionsreduce boilerplate - Cleaner null handling:
has_*+ throwing pattern reduces defensive checks
Related Task Directories
devdocs/types-api/- Type and constant creation refactordevdocs/value-api/- Value inspection methodsdevdocs/attribute-api/- Function/CallInst attributesdevdocs/metadata-api/- Metadata operationsdevdocs/dibuilder-api/- Debug info builder methodsdevdocs/module-api/- Module-level operations