Skip to content

Commit f7c704f

Browse files
committed
added extensions design doc
1 parent 18b4048 commit f7c704f

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# extensions
2+
3+
> planned for `0.0.1a3` — not yet implemented
4+
5+
an extension adds methods and computed properties to an existing type without
6+
subclassing it or touching its definition:
7+
8+
```by
9+
extension list:
10+
def second(self) -> _T:
11+
return self[1]
12+
```
13+
14+
`xs.second()` is then available on any `list`, including the builtin one. the
15+
extension reuses `list`'s own type parameter `_T` — it does not declare a new
16+
one — so the return type tracks the element type with no extra ceremony
17+
18+
## why swift-style
19+
20+
three languages solve "where do the extended type's parameters come from"
21+
differently. the choice matters because of one failure mode: a parameter that
22+
is used before it is bound
23+
24+
kotlin re-declares the parameters on the receiver and again on the method:
25+
26+
```by
27+
# kotlin-style, rejected
28+
def list[T].foo[T: int](self) -> T: ...
29+
```
30+
31+
`T` appears in `list[T]` before the `[T: int]` that declares it, and the two
32+
`T`s are different parameters that happen to share a spelling. imports are
33+
explicit — every extension must be named to be used
34+
35+
dart declares two separate parameter lists and binds one to the other:
36+
37+
```by
38+
# dart-style, rejected
39+
extension[T] on list[T]:
40+
def foo[R](self, r: R) -> T | R: ...
41+
```
42+
43+
this is unambiguous but verbose, and the binding `[T] on list[T]` restates a
44+
fact the declaration of `list` already records
45+
46+
swift reuses the extended type's parameters directly, by the names its
47+
declaration gave them:
48+
49+
```by
50+
extension list:
51+
def foo[R](self, r: R) -> _T | R: ...
52+
```
53+
54+
`_T` is not a new parameter and not a sigil — it is the name `list`'s
55+
declaration bound (`class list[_T]` in typeshed). because it refers to an
56+
existing declaration, it can never be used before it is defined. a method may
57+
still introduce its own fresh parameters (`[R]` here), declared normally on the
58+
method. basedpython takes the swift model
59+
60+
## reusing declared parameters
61+
62+
inside an extension, the extended type's parameters are in scope under the
63+
names its declaration used. for a first-party class the names are yours:
64+
65+
```by
66+
class Stack[T]:
67+
_items: list[T]
68+
69+
extension Stack:
70+
def peek(self) -> T:
71+
return self._items[-1]
72+
```
73+
74+
for a typeshed type the names follow typeshed convention (`_T`, `_KT`, `_VT`):
75+
76+
```by
77+
extension dict:
78+
def invert(self) -> dict[_VT, _KT]: ...
79+
```
80+
81+
referencing a name the extended type did not declare is an error — there is no
82+
implicit free-parameter introduction, which is exactly what keeps the
83+
"used before defined" case unreachable
84+
85+
## conditional extensions
86+
87+
a bound on a reused parameter narrows where the extension applies. it does not
88+
re-declare the parameter — it constrains the receiver:
89+
90+
```by
91+
extension list[_T: int]:
92+
def total(self) -> int:
93+
return sum(self)
94+
```
95+
96+
`total` is visible on `list[int]` and `list[bool]`, not on `list[str]`. this is
97+
swift's `where _T: int`, spelled with basedpython's existing bracket-bound
98+
syntax so it reads like every other bound in the language. parameters left out
99+
of the bracket stay reused, unconstrained
100+
101+
constraint applicability is resolved by the type checker per call site, so an
102+
extension can overlap a builtin or another extension and only apply to the
103+
arm that satisfies its bound
104+
105+
## what an extension may add
106+
107+
extensions add behaviour, not state — methods, `class def`/`static def`
108+
methods, and computed [properties](properties.md). they may not add stored
109+
fields, because there is nowhere to store them on an already-constructed
110+
instance of a builtin. this is the same boundary swift draws, and it keeps the
111+
feature implementable without touching object layout
112+
113+
```by
114+
extension str:
115+
@property
116+
def shouty(self) -> str:
117+
return self.upper()
118+
```
119+
120+
## implicit imports
121+
122+
importing a module makes its extensions applicable — there is no per-extension
123+
import. a plain `import mod` is enough for the type checker to consider every
124+
extension `mod` defines:
125+
126+
```by
127+
import textwrap
128+
129+
# textwrap's extensions on `str` are now in scope
130+
greeting.dedented()
131+
```
132+
133+
the transpiler wires up the runtime side automatically (see below), so
134+
`import mod` carries the extensions without `from mod import dedented` ever
135+
being written by hand
136+
137+
## lowering
138+
139+
python has no extension methods, and builtin C types cannot be monkey-patched
140+
at runtime, so extensions are resolved entirely at transpile time — no runtime
141+
machinery, the same approach the rest of basedpython takes
142+
143+
each extension member lowers to a module-level free function whose first
144+
parameter is the receiver:
145+
146+
```by
147+
extension list:
148+
def second(self) -> _T:
149+
return self[1]
150+
```
151+
152+
153+
154+
```python
155+
def __by_ext__list__second(self):
156+
return self[1]
157+
```
158+
159+
call sites are rewritten by the type checker. ty already knows the receiver's
160+
type and which extensions are in scope, so `xs.second()` resolves to the
161+
backing function and lowers to a plain call:
162+
163+
```by
164+
xs.second()
165+
```
166+
167+
168+
169+
```python
170+
__by_ext__list__second(xs)
171+
```
172+
173+
computed properties lower the same way, minus the call parentheses:
174+
`name.shouty``__by_ext__str__shouty(name)`
175+
176+
because the rewrite is type-directed, an extension call is never confused with a
177+
real attribute. a method that happens to share a name with a real attribute
178+
loses to the real attribute — extensions never shadow declared members
179+
180+
### implicit imports, lowered
181+
182+
when a call site uses an extension defined in another module, the lowering emits
183+
the precise import of the backing function into the output, keyed off the
184+
provenance ty recorded:
185+
186+
```by
187+
import textwrap
188+
189+
greeting.dedented()
190+
```
191+
192+
193+
194+
```python
195+
from textwrap import __by_ext__str__dedented
196+
197+
__by_ext__str__dedented(greeting)
198+
```
199+
200+
so the surface stays `import textwrap`, and only the functions actually used are
201+
imported — the implicit-import convenience costs nothing at runtime
202+
203+
## round-tripping
204+
205+
the reverse transpiler re-sugars both halves from `LoweringMap` provenance:
206+
a free function tagged `ExtensionMethod` becomes an `extension` block, and a
207+
call tagged `ExtensionCall` becomes receiver-method form. backing functions and
208+
calls written by hand without that provenance are left as ordinary python

docs/basedpython/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ basedpython is a Python-like language that transpiles to pure Python
6161
- [mutable default arguments](features/mutable-defaults.md)
6262
- [dedented triple-quoted strings](features/dedent-strings.md)
6363
- [custom string tags](features/string-tags.md)
64+
- [extensions](features/extensions.md)
6465
- [tuple member access (`expr.N`)](features/tuple-index.md)
6566
- [keyword arguments in subscripts](features/kw-subscript.md)
6667
- [unpack syntax](features/unpack-syntax.md)

0 commit comments

Comments
 (0)