コンテンツにスキップ

値と型

ツアーの続きです。 Value クラス、インターフェース、メモリ管理、直和型、Optional と Enum を見ます。

Value クラスとインターフェース

データそのものはValue クラスで持ちます。 @value を付けたクラスが、ネイティブの構造体にコンパイルされます(@dataclass(frozen=True) と同じ意味なので、そちらの綴りでも書けます)。 Value クラスは不変なので、書き換えるときは replace で新しい値を作ります。 フィールドには、先に宣言した別の Value クラスも置けます(入れ子の値)。

from dataclasses import replace


@value
class Point:
    x: int
    y: int = 0

sel: State[Point] = State(Point(3, 4))
sel.set(replace(sel(), x=10))
text(f"x={sel().x}")

Value クラスにはメソッドも書けます。 演算子の特殊メソッド(__add__、__sub__、__mul__)を定義すると、+ - * がそのメソッドを呼びます(開発中は Python がそのまま呼び、リリース版では同じ計算がコンパイルされて走ります)。 メソッドの本文は return 式 の一文だけです(不変の値には代入するものがないためです)。

@value
class V2:
    x: int
    y: int

    def __add__(self, o: "V2") -> "V2":
        return V2(self.x + o.x, self.y + o.y)

    def __mul__(self, k: int) -> "V2":
        return V2(self.x * k, self.y * k)

    def dot(self, o: "V2") -> int:
        return self.x * o.x + self.y * o.y

c.set(a() + b() * 2)      # 演算子は特殊メソッドへ
d.set(a().dot(b()))       # 普通のメソッドはハンドラから

インターフェースは typing.Protocol です。 Protocol を基底に挙げたモデルが、その実装になります。 Protocol 型の引数を取るヘルパは、どの実装を渡しても動きます(実装ごとに特殊化してコンパイルされます)。

class Shape(Protocol):
    def area(self) -> float: ...


@model
class Circle(Shape):
    r: float = 1.0
    def area(self) -> float:
        return self.r * self.r * 3.0


def area_of(s: Shape) -> float:
    return s.area()

メモリ管理

手で解放するものはありません。 覚えることは二つだけです。

  • 値(Value クラス、リスト、辞書、文字列)は、コピーとして渡されます。 渡した先で書き換わっても、元の側は変わりません。 リリース版は書き換わる瞬間まで実体を共有する(コピーオンライト)ので、大きなリストを渡しても複製のコストはかかりません。
  • モデル(と、それを持つストア)は参照です。 リリース版は参照カウントで管理します。 最後の参照が外れると、その代入の時点で解放します。 ヒープを走査する GC はなく、停止もありません。

この二つから、日々の作法が決まります。

  • データは Value クラスとリストで持ち、ストアのフィールドに置きます。 モデルにするのは「共有されて、書き換わって、画面が追随する」ものだけです。
  • ハンドラの中で作って外に渡さなかったモデルは、ハンドラを抜けた時点で解放されます。 ループの中で作る一時オブジェクトも同じです。
  • 所有のつながりを断てば(self.root = None)、その下がまとめて解放されます。 生き残った側から Weak を読むと None が返ります。

循環だけが例外です。 互いに所有し合うオブジェクトは、外から所有のつながりを断っても、互いに相手を手放しません。 リリース版では解放されず、そのまま残ります(リークであって、クラッシュではありません)。 逆向きの参照を Weak にして、循環を作らないのが作法です。 なお開発中の CPython には循環回収があるので、循環を作ってしまったときのメモリの振る舞いだけは、二つの実行で同じになりません。 ゲートが比べるのは画面で、メモリは検証の対象外だからです。 代わりに、check が次の二つを警告します。 フィールドの型が二つ以上のモデルにまたがって輪になっているときと、ハンドラに a.kid = b、b.parent = a と往復を書いたときです。

生きているオブジェクトの数は、ヘッドレス実行の mem ステップでいつでも数えられます。

直和型と match

Value クラスを type エイリアスで束ねると、match で分岐できる選択肢の型(直和型)になります。 match はハンドラでもビューでも使え、case Degraded(services): のような分解もそのまま書けます。

@value
class Healthy: pass


@value
class Degraded: services: int


@value
class Outage: service: str

type Health = Healthy | Degraded | Outage

health: State[Health] = State(Healthy())

# ビューの中で
match health():
    case Healthy():
        text("ALL SYSTEMS NOMINAL")
    case Degraded(services):
        text(f"DEGRADED — {services} service(s)")
    case Outage(service):
        text(f"OUTAGE — {service} is down")

case の抜けはコンパイル時に指摘されます。 バリアントのフィールドにデフォルト値は書けません。 一つのバリアントが属せる直和型は、一つだけです。 case にはガードと | の並記が書けます。 ガードが成り立たないときは、Python と同じく下の case に落ちます。

match health():
    case Degraded(services) if services > 3:
        text("badly degraded")
    case Healthy() | Degraded(_):
        text("fine enough")
    case _:
        text("down")

Optional と Enum

Optional は状態にもフィールドにも書けます(last: int | None = None)。 絞り込みは walrus の節で見たとおりです。

Enum は class Mood(Enum) と普通に書けば、そのままコンパイルされます(import enum と class Mood(enum.Enum) の形も同じです)。 .name と .value は Python と同じ値を返します(auto() は 1 から数えます)。 for m in Mood: は宣言順にメンバーを回ります。 match の case に書けるのは Mood.MEMBER か _ で、抜けはここでも指摘されます。 テキストに埋め込むと、Python と同じ Mood.HAPPY の形で描画されます。 repr(m) は、メンバーについて Python が書くもの(<Mood.HAPPY: 1>)を返します。 int、float、bool、bytes の repr も、Python が書くものと同じです。 str の repr はまだ方言にありません。 Python は Unicode の表で表示できないとされる文字をエスケープしますが、コンパイル側はその表を持っていないからです。

match は int、float、str、bool の値も取ります。 | で候補を並べたり、if でガードを付けたりもできます。

match code():
    case 0 | 1:
        note.set("early")
    case n if n > 100:
        note.set("far")
    case _:
        note.set("middle")

型の名前

型を付けたモジュールは、使う前に型に名前を付けます。 方言はその名前を、型チェッカーと同じように読みます。

import enum
from typing import NamedTuple, NewType, TypeAlias

Gram = NewType("Gram", int)          # 単位。コンパイル側には int


class Size(enum.Enum):
    SMALL = "small"
    LARGE = "large"
    HUGE = "huge"


class Parcel(NamedTuple):
    weight: Gram
    size: Size


Parcels = list[Parcel]               # 型の別名
Fees: TypeAlias = dict[str, int]     # 同じことを明示した形
type Labels = list[str]              # 新しい書き方
_len = len                           # 組み込み関数に付けた、モジュール自身の名前

OVERSIZE = frozenset({Size.LARGE, Size.HUGE})


def oversize_weight(parcels: Parcels) -> Gram:
    total = Gram(0)
    for p in parcels:
        if p.size in OVERSIZE:
            total = Gram(total + p.weight)
    return total

NewType は、型チェッカーが元の型と区別するための名前です。 実行時には、渡された値をそのまま返すだけです(Gram(5) は 5 を返します)。 そのため、注釈に書いた NewType は元の型として読み、呼び出しの引数はその元の型であることを確かめます(Gram(1.5) は受け付けません。Python は float をそのまま返すからです)。 isinstance(x, Gram) は、Python が TypeError を出すのと同じ理由で受け付けません。 NewType にはクラスがないからです。 別名は、三つの書き方のどれでも、その型そのものです。 Value クラスを | でつないだものは直和型で、type Shape = Circle | Rect の書き方のままです。 組み込み関数、関数、クラスに付けた別名は、呼ぶ場所でその本体と同じです。 ただし、元の名前を関数が自分で定義している場合は受け付けず、関数の側の名前を変えるように求めます。 そこでも Python の別名は組み込みのほうを読み続けるからです。 リテラルの frozenset と、その場で書いた集合(x in {A, B})は、in と not in に答えます。 集合が両方の実行で同じに答えるのは、この問いだけです(どの順で回るかによらないからです)。

コンパイル側が自分のために使っている名前を、クラスやローカルに付けることもあります。 Element や List という名前のクラス、state、case、_ という名前のローカルです。 デコレータのないクラス(NamedTuple、Enum、例外)と、関数の引数とローカルは、コンパイル側だけで別の名前になります。 その名前は誰にも見えません。 開発中の CPython の実行、Enum のメンバーの str()、メッセージは、書いたとおりの名前のままです。 ストア、モデル、Value クラスは名前で見つけられるので(開発中の実行はストアのメソッドを名前で探し、Value クラスは Rust の構造体と名前で対応します)、名前を変えられません。 これらには別の名前を付けるように求めます。