Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ cleaner interop, and a structured target later passes can operate on. Emitted Py
human-readable for debugging.

Representative mappings: `let x = e` → assignment; expression `if` → ternary `IfExp`; `match` →
Python `match` (3.10+) or an if-chain; `x |> f |> g` → `g(f(x))` (the pipe is pure
parse/lowering-time sugar, no runtime cost).
Python `match` (3.10+), or an `isinstance` ladder for the built-in two-case types (§5.5), or an
if-chain for active patterns; `x |> f |> g` → `g(f(x))` (the pipe is pure parse/lowering-time
sugar, no runtime cost).

**Currying lowering (curried in the type system, n-ary in the output).** Functions are curried by
default (§7), but naive currying would emit `add(1)(2)` everywhere — unreadable and slow. Because
Expand All @@ -170,7 +171,11 @@ section is unchanged.
**Representation contracts.** ADTs, records, tuples, options/results, and curried/partially-
applied functions each need a *stable* Python representation. That representation is a public
contract — emitted code and interop both depend on it — so changing it is a breaking change, not
an implementation detail.
an implementation detail. The contract today: every ADT variant, record and prelude class is a
frozen, **slotted** dataclass (`@dataclass(frozen=True, slots=True)`), so an instance holds exactly
its fields and has no `__dict__`; a field-less variant is additionally backed by one module-level
instance (§5.5) that every use of the constructor as a value loads, though constructing through the
class by hand stays valid and equal to it.

**Names Python already owns.** A Pyfun name is emitted as itself, with one exception: the emitted
module is a Python namespace the compiler is also using. A binding whose name is a Python **keyword**
Expand Down Expand Up @@ -285,6 +290,49 @@ precondition stopped it (`` note: `collect` calls itself in tail position but ke
form: a closure in it captures `n` ``). Nothing is reported for the overwhelming majority of
functions, which have no self tail call at all. Mechanics in `INTERNALS.md`.

### 5.5 `Option`/`Result` matches as `isinstance` ladders; nullary constructors as singletons

`Option` is the return type of every stdlib accessor (`Map.tryFind`, `List.head`, `List.findIndex`),
so a `match` on one is the commonest match in the language, and a real program asks it millions of
times. A Python `match` resolves a class pattern through `__match_args__` and `isinstance` at
runtime, then binds by position, which is structural machinery a two-case type with one payload does
not need. A match whose arms are the constructors of one built-in family (`Some p`/`None`, or
`Ok p`/`Error e`) with irrefutable payload patterns, optionally ending in a catch-all, therefore
lowers to what a person writes:

```python
def allowsLetter(checks, rc, l): # match Map.tryFind rc checks:
_pf_t0 = _pf_map_try_find(rc, checks)
if isinstance(_pf_t0, None_): # case None: true
return True
else: # case Some s: Set.contains l s
s = _pf_t0._0
return l in s
```

The last arm of an exhaustive match is a plain `else`, and the `raise RuntimeError("non-exhaustive
match")` the `match` lowering keeps as a belt-and-braces guard is dropped: the checker has proved
the arms cover the type, and the ladder's own rule (both constructors unguarded, or an unguarded
catch-all) is the checker's rule for this shape. A guarded arm in return position is its own `if`,
so a failed guard falls through to the arms after it exactly as `case … if …:` does; in value
position a guard would need a matched-flag to do the same, so a guarded value-position match keeps
the `match` lowering. A refutable payload pattern (`case Some 0:`, `case Some (Some x):`) keeps it
too. The `result {}`/`option {}` computation expressions use the same ladder for every `let!`,
forwarding the failure as the value it already is. User ADTs still lower to `match`/`case`: the
readable structural output is the point of the emitter, and only the built-in family is hot enough,
and simple enough, to earn the exception.

A field-less constructor has one possible value, so building a fresh instance at every mention
(`rules.Across()`, `None_()` on every miss) is work for nothing. Each nullary variant, and the
prelude's `None`, is therefore built **once**, right after its class, as a module-level singleton
named after the class with a leading underscore (`_Across = Across()`, `_None_ = None_()`), and
every use of the constructor as a value loads it. Equality is untouched: the dataclass compares by
class and (absent) fields, so `_Across == Across()` holds and a hand-constructed instance matches
`case Across():` as before; what changes is that `d == Across` in a hot loop is now a name load and
an identity check that short-circuits in C. A program that binds the singleton's own name
(`let _Across = …`) keeps the call form for that constructor, so the rewrite can never shadow a
user binding. Mechanics in `INTERNALS.md`.

## 6. Python interop — the hard boundary

Every functional guarantee is either enforced *before* lowering or consciously *relaxed* at the
Expand Down Expand Up @@ -1083,8 +1131,8 @@ target reads one attribute per field it names. The target is held to irrefutabil
check a parameter's is, so `let Some x = …` is rejected with a message naming what was written and
pointing at `match`, which has somewhere to fall through to. Three positions take a target: a
top-level `let`, a block-local `let`, and a computation expression's `let`/`let!` — where a
destructuring bind costs nothing extra, because `result`'s pattern rides inside the `Ok` it already
matches (`case Ok((r, c)):`). What cannot destructure is a binding that needs a single name to
destructuring bind is one unpacking statement from the payload `result` has already tested
(`r, c = x._0`). What cannot destructure is a binding that needs a single name to
*be*: a function binding (`params` are what make it one) and a `let mut` (whose name is what `<-`
reassigns) both reject a pattern target, each with its own message. Each name a destructuring
binding introduces is generalized on its own, the same let-generalization a single-name binding
Expand Down Expand Up @@ -1626,10 +1674,11 @@ The four built-ins and how they lower to Python:
| `option {}` | short-circuit on `None` | the `Option` ADT, the same early-return chain; pure but short-circuiting |

`result` and `option` are one lowering (`lowering::ShortCircuit`) differing only in how they spell
their two cases, so neither can drift from the other. `option`'s is the simpler: `None` carries no
payload, so its failure arm binds nothing and returns a fresh one (`case None_(): return None_()`)
where `result`'s must capture the error value to forward it. Both emit a flat sequence of `match`
statements with early returns, which is the whole reason they are built in: the user-builder path is
their success case, so neither can drift from the other. Each `let!` is the `isinstance` ladder of
§5.5: `if isinstance(r, Ok): x = r._0 … else: return r`, the failure forwarded as the value in hand
(an `Error e` rebuilt as `Error(e)` would be structurally equal, and Pyfun exposes no identity to
tell them apart). Both emit a flat sequence of tests with early returns, which is the whole reason
they are built in: the user-builder path is
an *expression* transform, so the same program written against a hand-rolled `Opt` module compiles
to a chain of nested `bind` lambdas on one line. A CE earns its keep at **two or more** binds; a
single bind reads better as `Option.map`/`Option.bind`.
Expand Down
38 changes: 38 additions & 0 deletions INTERNALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,44 @@ each operand bound to a fresh temp unless `is_atomic`, `v` bound from the payloa
`shadow_local_fns` discipline around the `Some` arm. No `Option` is constructed, so a program whose
only `Option` was this one emits no `Some`/`None_` classes at all. Anything else falls through to the
`match`, unchanged.
### `Option`/`Result` matches as `isinstance` ladders — implements DESIGN §5.5

`Lowerer::try_lower_ladder_match` runs ahead of the generic `match` lowering in both positions
(`lower_return` and `lower_value`). `ladder_shape` is the gate, and it is purely syntactic: every arm
must be a constructor of one built-in family (`LadderFamily::of_ctor` — `Some`/`None` or `Ok`/`Error`)
whose payload pattern is `simple_irrefutable` (a variable, `_`, or a tuple of those, nested), or a
catch-all (`_`/a variable, which binds the whole scrutinee); arms after an unguarded catch-all are
dropped as dead. Anything else returns `None` and the arms lower to `match`/`case` as before. The
scrutinee is lowered once and bound to a temp unless it is already a name. Each arm becomes a
`Piece` (its `isinstance` test, the payload binding via `bind_irrefutable`, then the body under its
guard if any), and the chain is assembled back to front: an unguarded arm nests the rest as its
`orelse` (the emitter renders that as `elif`/`else`), a **guarded** arm cannot (its guard failing
must reach the later arms), so the rest follows it as statements, which is only sound in return
position where every body returns, hence a guarded value-position match is rejected before any
lowering happens. Exhaustiveness is the ladder's own rule (both constructors unguarded, or an
unguarded catch-all, judged on the source arms): then the last arm is emitted with no test and no
defensive raise follows. In value position the result temp is allocated before the shape check so the
ladder can name it, and handed back (`tmp_counter` restored) on rejection, so the `match` path
numbers its temps exactly as it did. `short_circuit_bind` gives `result {}`/`option {}` the same
shape for each `let!`/`do!` (`bind_ce_target` unpacks the payload; the failure is `return <subject>`).

Two things the pass deliberately does not do: it does not test with `is` against the singleton
(a hand-built `Some`/`None_` from Python must still match), and it does not extend to user ADTs.

### Nullary constructor singletons — implements DESIGN §5.5

`Lowerer::new` computes `nullary_singletons`: every zero-field constructor in `ctor_arity` (user
variants plus the prelude's `None`, minus hidden active-pattern cases) whose singleton name
(`nullary_singleton_name`: `_` + the emitted class name) is not in `binder_names`, the whole-module
binder set (`module_binder_names`, shared with the module-alias shadow check). `lower_module` emits
`_Red = Red()` (`singleton_assign`) right after each such class; `option_prelude` does the same for
`None_` (always, in `_pyfun_rt.py`). `nullary_value` is the one place a constructor-as-value is
spelled (`lower_var`, the decode specializer's `None`), and the prelude helpers reach it through
`none_value`/`none()` (only `collection_prelude` constructs `None` values). Cross-module: the project
driver computes each module's singleton set with the same two functions and passes it as
`ImportContext::nullary_singletons`, so an importer emits `palette._Red` exactly when `palette.py`
defines it and `palette.Red()` otherwise; a project module that needs `Option` imports `_None_` from
the runtime alongside `Some`/`None_` unless it binds that name itself.

### Specializing statically-known `Decode` decoders — implements DESIGN §5.3

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ print (report [1.0, 2.0, 3.0, 4.0])
from dataclasses import dataclass
import statistics

@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class Summary:
n: int
mean: float
Expand Down
9 changes: 6 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,12 @@ map_build 1.64× vs hand-written.
output** on the ADT-heavy workload, landing near hand-written speed — the L is justified on these
numbers. Two riders: (1) roughly half the gap closed *before* compilation — CPython's
class-pattern `match` dispatch is expensive (Windows 3.14 ablation: 2.31× → 1.44× from the
rewrite alone), so the `if`/`isinstance` lowering mypyc forces is also a candidate lever on its
own, though it trades away the readable `match`/`case` output the default emitter promises —
native-mode-only unless a real workload demands otherwise; (2) frozen-dataclass ADTs compiled
rewrite alone), so the `if`/`isinstance` lowering mypyc forces is also a lever on its own. **It
shipped in the default emitter for `Option`/`Result` scrutinees on 2026-08-30** (`DESIGN.md`
§5.5, issues #87/#89): a real workload (a Scrabble move generator asking `Map.tryFind`/`List.findIndex`
millions of times per position) demanded it, and for a two-case type with one payload the ladder
*is* the readable form. User ADTs keep `match`/`case`, so native mode still needs the general
`if`/`elif` lowering; (2) frozen-dataclass ADTs compiled
fine — mypyc's remaining headroom (native classes vs dataclasses, boxed union fields) is upside
not yet claimed.
- **Native backend** (not planned — recorded as a design-space note so the property it rests on
Expand Down
2 changes: 1 addition & 1 deletion docs/src/educators/session-3.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ students see records become frozen dataclasses.
2. Explain the copy-and-update line `{ opened with balance = 100 }`. It builds a fresh `Account` and
leaves `opened` alone, which is why the original balance is unchanged. Connect this back to the
immutability from Session 1.
3. Show the Python panel: `Account` compiled to a `@dataclass(frozen=True)`, and the update compiled
3. Show the Python panel: `Account` compiled to a `@dataclass(frozen=True, slots=True)`, and the update compiled
to a new `Account(...)` call. The immutability is literal in the output.
4. Break it live: change `Account { name = "Ada", balance = 0 }` to drop the `balance` field. The
compiler reports the missing field. Restore it. This shows records require all their fields.
Expand Down
15 changes: 8 additions & 7 deletions docs/src/internals/03-desugaring.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,17 @@ The built-ins lower directly rather than through this pass, so it is worth seein
```python
def safeDiv(a, b):
def _pf_fn0():
match Error("div by zero") if b == 0 else Ok(a):
case Ok(x):
return Ok(x / b)
case Error(_pf_t0):
return Error(_pf_t0)
_pf_t0 = Error("div by zero") if b == 0 else Ok(a)
if isinstance(_pf_t0, Ok):
x = _pf_t0._0
return Ok(x / b)
else:
return _pf_t0
return _pf_fn0()
```

That is railway-oriented short-circuiting written as a real `match`: on `Ok` it continues, on
`Error` it returns the error unchanged. A generic bind/return desugaring would produce a chain of
That is railway-oriented short-circuiting written as a plain test: on `Ok` it unwraps and continues,
on `Error` it returns the value unchanged. A generic bind/return desugaring would produce a chain of
closure calls instead, which is why the built-ins keep their own lowering while user builders take
the desugaring path. The two mechanisms sit side by side: the same protocol shape, but the built-ins
earn idiomatic Python by not being desugared.
Expand Down
10 changes: 8 additions & 2 deletions docs/src/internals/08-emission.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ like idiomatic structural Python. The checker has already proven the match exhau
trailing `case _: raise RuntimeError(...)` is a belt-and-braces guard, not something the program
should ever hit.

The built-in `Option` and `Result` are the exception. A match on one of them is the commonest match
in the language and the cheapest to answer, so it emits as an `isinstance` ladder instead
(`if isinstance(o, Some): x = o._0 … else: …`), with no defensive arm, because the checker has
already proved the two cases cover the type. Field-less constructors get one more shortcut: the
class is built once (`_Dot = Dot()`) and every use of the constructor as a value loads that name.

## The running example, in full

Here is the complete emitted Python for the running example, straight from `pyfun compile`:
Expand All @@ -73,12 +79,12 @@ import functools
import math
def _pf_fold(f, acc, xs):
return functools.reduce(f, xs, acc)
@dataclass(frozen=True, repr=False)
@dataclass(frozen=True, slots=True, repr=False)
class Circle:
_0: float
def __repr__(self):
return f"Circle({self._0!r})"
@dataclass(frozen=True, repr=False)
@dataclass(frozen=True, slots=True, repr=False)
class Rect:
_0: float
_1: float
Expand Down
2 changes: 1 addition & 1 deletion docs/src/learn/06-records.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ update hands you a fresh value and the original stays put. The emitted Python ma

```python
from dataclasses import dataclass
@dataclass(frozen=True)
@dataclass(frozen=True, slots=True)
class Point:
x: int
y: int
Expand Down
18 changes: 8 additions & 10 deletions docs/src/learn/20-build-your-own-ce.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ computation expression is just method calls, and any in-file `module` (lesson 15
Here is a builder for the `Option` type, so `Maybe { }` chains steps that might be `None` and stops
at the first one. Pyfun ships `option { }` as a built-in (lesson 13), so this is a rebuild of
something you already have, which is exactly what makes it a good first builder: you can compare
your version against one that works. The built-in earns its place by lowering to flat `match`
statements with early returns, the way `result { }` does, where a builder written this way lowers
to nested `bind` calls. Everything else about them is the same.
your version against one that works. The built-in earns its place by lowering to flat `isinstance`
tests with early returns, the way `result { }` does, where a builder written this way lowers to
nested `bind` calls. Everything else about them is the same.

```pyfun
module Maybe =
Expand Down Expand Up @@ -43,13 +43,11 @@ rule in the compiler. The emitted Python shows the desugaring exactly:

```python
def Maybe_bind(m, f):
match m:
case Some(x):
return f(x)
case None_():
return None_()
case _:
raise RuntimeError("non-exhaustive match")
if isinstance(m, Some):
x = m._0
return f(x)
else:
return _None_
def Maybe_return_(x):
return Some(x)
def addOpt(a, b):
Expand Down
2 changes: 1 addition & 1 deletion src/lowering/decode_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ impl Lowerer {
},
body: vec![PyStmt::Assign {
target: r.clone(),
value: call("None_", vec![]),
value: self.nullary_value("None"),
}],
orelse,
});
Expand Down
Loading