|
| 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 |
0 commit comments