Skip to content

Commit 6dafb44

Browse files
committed
added safe variance design doc
1 parent ffc7f6f commit 6dafb44

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# safe variance
2+
3+
> planned for `0.0.1a3` — not yet implemented
4+
5+
a [covariant](variance.md) type parameter (`out T`) is only sound while `T`
6+
is never written through, nor read off of, a *widened* view of the class.
7+
the classic hole is a mutable `T`-typed field: `A[int]` is assignable to
8+
`A[object]` under covariance, and if the `A[object]` reference can write its
9+
field then the underlying `int` storage gets corrupted with a `str`
10+
11+
mainstream checkers close this hole by forbidding `out T` from appearing in
12+
any mutable or input position — which rules out a great deal of otherwise
13+
reasonable code. basedpython closes it with a narrower observation:
14+
**privacy is what makes variance safe**. a [private](modifiers.md) member is
15+
invisible to external observers, so it cannot be used to distinguish two
16+
specializations of the same class, so it cannot break variance. the type
17+
checker leans on this in three places
18+
19+
## private members do not specialize through a widened view
20+
21+
a private member is only accessible through a `self` whose type parameter is
22+
exactly the enclosing class's own — never through a foreign or widened
23+
specialization of the same class. that single rule is what permits a mutable
24+
`T`-typed field under `out T`:
25+
26+
```by
27+
class A[out T]:
28+
private t: T
29+
30+
def f(self, other: A[object]):
31+
print(other.t) # error — privacy violation
32+
other.t = 1 # error — privacy violation
33+
34+
def g(self: A[object]):
35+
self.t = "asdf" # error — privacy violation
36+
```
37+
38+
`other: A[object]` is a *different* specialization than the `A[T]` whose body
39+
we are inside, so its private `t` is off-limits even from within class `A`.
40+
`self: A[object]` is the same offence written on the receiver: an explicitly
41+
widened `self` is no longer the precise `A[T]`, so its `t` is unreachable too
42+
43+
the only place `t` *is* reachable is through a `self` carrying the exact `T`.
44+
there the field's declared type and the receiver's type argument coincide, so
45+
both reads and writes are sound. covariance never gets a chance to corrupt the
46+
field because the only reference that can touch it is the one that already
47+
knows the real `T`
48+
49+
without this rule the field would have to be invariant (`A[int]` not
50+
assignable to `A[object]`), losing the covariance the author asked for. the
51+
privacy boundary buys back the assignability while keeping it sound — the
52+
mutable field simply does not exist as far as a widened observer is concerned
53+
54+
## `SafeVariance[T]` — consuming `T` at its upper bound
55+
56+
sometimes a covariant class genuinely needs a method that *takes* a `T`.
57+
ordinarily `out T` may not appear in an input position at all. `SafeVariance`
58+
is the escape hatch: a parameter typed `SafeVariance[T]` is checked against the
59+
actual specialization at the **call site**, but is seen as the **upper bound of
60+
`T`** inside the body:
61+
62+
```by
63+
class A[out T]:
64+
private t: T
65+
66+
def f(self, t: SafeVariance[T]):
67+
reveal_type(t) # object — the upper bound of T
68+
self.t = t # error — object is not assignable to T
69+
70+
a = A[int]()
71+
a.f("asdf") # error — call site checks against the real T (int)
72+
a.f(1) # ok
73+
```
74+
75+
the two halves pull in opposite directions on purpose:
76+
77+
- at the call site, `SafeVariance[T]` behaves like `T`, so the specialization
78+
is enforced — `a: A[int]` accepts `f(1)` and rejects `f("asdf")`. callers
79+
cannot smuggle an arbitrary value in
80+
- inside the body, `t` is widened to `T`'s upper bound (`object` here), so the
81+
body learns nothing more than the bound. crucially it cannot store the value
82+
back into the covariant field: `self.t` wants a `T`, the body only has an
83+
`object`, and `object` is not assignable to `T`
84+
85+
that second half is the soundness guard. the body can read the consumed value,
86+
log it, compare it — anything that treats it as its bound — but it can never
87+
funnel it into `T`-typed covariant storage, which is the only operation that
88+
could violate covariance. the value flows *in* at full precision and is
89+
immediately *erased* to the bound, so it can never flow back out mislabelled
90+
91+
> the `SafeVariance[T]` surface annotation is reserved for a future release.
92+
> it is documented here as the intended spelling; until the annotation syntax
93+
> lands, the mechanism above is the specified behaviour, not yet a usable form
94+
95+
## private members are bivariant, not covariant
96+
97+
variance inference normally treats a mutable `T`-typed attribute as forcing
98+
`T` **invariant** (it is both read and written), and a read-only one as
99+
covariant. a *private* attribute contributes neither constraint, because no
100+
external observer can tell two specializations apart through it. with no
101+
constraint from any externally-visible position, the inference defaults to
102+
**bivariant**:
103+
104+
```by
105+
class A[T]:
106+
_t: T # private — does not constrain variance
107+
```
108+
109+
here `T` is inferred bivariant: both `A[int] -> A[object]` and
110+
`A[object] -> A[int]` are allowed, because there is no observable use of `T`
111+
anywhere on the public surface to distinguish them. a single-underscore name
112+
is private, so `_t` is exactly the case the first section made sound — and a
113+
field that cannot break variance cannot constrain it either
114+
115+
this is strictly more permissive than the covariant guess a naive reading of
116+
"`T` is only read" would produce, and it is sound for the same reason: the
117+
field is invisible from outside, so neither direction of assignment can be
118+
caught misbehaving. the moment any public member mentions `T`, that member's
119+
position drives the inference in the usual way and the bivariance disappears
120+
121+
## why privacy is the common thread
122+
123+
all three behaviours are one principle applied three ways. a private member is
124+
not part of the type's observable interface, so:
125+
126+
1. it may be a mutable field under `out T`, because only the precisely-typed
127+
`self` can reach it (section 1)
128+
1. it may be the sink for a `SafeVariance[T]` parameter's value — except the
129+
body is handed only the bound, so the sink stays unreachable in practice
130+
(section 2)
131+
1. it imposes no variance constraint at all, leaving `T` bivariant when nothing
132+
public mentions it (section 3)
133+
134+
soundness comes from the same fact each time: you cannot observe, through a
135+
widened reference, a difference that a private member would have introduced

docs/basedpython/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ basedpython is a Python-like language that transpiles to pure Python
3636
- [anonymous named tuple types](features/anonymous-named-tuple.md)
3737
- [explicit typevar constraints](features/constraints.md)
3838
- [typevar variance keywords](features/variance.md)
39+
- [safe variance](features/safe-variance.md)
3940
- [explicit generic call sites](features/generic-calls.md)
4041
- [reified type parameters](features/reified-generics.md)
4142
- [automatic forward references](features/forward-references.md)

0 commit comments

Comments
 (0)