制御フローとデータ
ツアーの続きです。 ハンドラに書けること、値としての関数、CPython と同じ意味を持つ算術、リスト、チャート、辞書、タプルを見ます。
ハンドラと制御フロー
ハンドラは三つの形で渡せます。
lambda(複数の操作はタプル lambda: (a.set(x), b.set(y)))、モジュールレベルの def、そしてストアのメソッド参照(on_click=Cart.clear)です。
デコレータもコンパイルできます。 デコレートが起きるのはインポート時ですが、コンパイル済みのアプリはモジュールを実行しません。 そこでラッパは、デコレートされたハンドラの本体に畳み込まれます。
def announced(f):
def wrapper():
status.set("working")
f()
status.set("done")
return wrapper
@announced
def save():
fs.write_text(path, body())
デコレータは引数を一つ取る def で、受け取った関数をそのまま返すか、その関数を一度だけ呼ぶラッパを定義して返します。 デコレータ自身が引数を取る形、関数を二度呼ぶラッパ、値として使うラッパは断られます。
def ハンドラの中身は、if や for などの制御フローごとコンパイルされます。
def double(v: int) -> int: # 純粋ヘルパはネイティブの関数になる
return v * 2
def tally():
total.set(0)
for i in range(1, 6):
if i == 3:
continue
total.set(total() + double(i))
if / elif / else、while(while True: も含みます)、for(range()、リストの状態、リストのフィールド、リスト型の引数とローカル)、break / continue、ローカル変数(Python と同じく再代入可)が使えます。
ローカルには型を書けます(total: int = 0、best: Row | None = None)。
数と str には、+= や -= などの累算代入も使えます。
値を返す関数を、値を使わずに文として呼ぶこともできます(fee(p) だけの行)。
print(...) は stdout に書きます。
sep= と end= も Python と同じように使えます。
log("…") のほうは stderr に一行書きます。
どちらの実行も同じバイト列を書き、ゲートは画面と同じようにアプリが書いた内容も比べます。
ヘッドレス実行のダンプは PIXIE_DUMP が指すファイルに出るので、二つの出力が混ざることはありません。
assert と raise は、Python の例外と同じようにその文を終わらせます(アプリは動き続けます)。
条件には bool をそのまま書けます(if on:)。
比較の連鎖(0 < n < 10、中央は一度だけ読みます)と、:= での束縛も使えます。
is は、Enum のメンバーと bool に対しては == と同じに比べます(if verdict is Pick.SKIP:)。
どちらも、その値のオブジェクトが一つしかないからです。
Optional の場合は、その値を持っているかを聞きます(flag: bool | None の flag is True は、flag が None なら False です)。
ほかの値の is は受け付けず、== を求めます。
比較は値でもあります。
flag.set(n() > 1)、ok = a == b、bool を返すヘルパが書けます。
条件式(a if c else b)は Python で書ける場所ならどこでも書けます。
ハンドラの中でも、ビューの中でもです。
両の枝に置けるのは int、float、str、bool の値です。
純粋ヘルパ(引数と返り値を注釈し、return 式 で終わる関数)はハンドラからもビューのテキストからも呼べます。
分岐の途中で return してよく、自分自身も呼べます。
list[...] の引数と既定引数を取り、Value クラスやリストを返せます。
ヘルパの引数は T | None と書けて、Python と同じく素の値も None も渡せます。ヘルパ自身も T | None を返せます(空のほうは return None)。
ヘルパが読めるのは渡されたものだけです。
ヘルパの中で State を読み書きすると受け付けません。コンパイルされたヘルパの周りにはアプリが無いからです。State はハンドラで読み、値を渡します。
ストアやモデルのメソッドは T | None を返せます。
空のほうは return None と書き、受け取る側で絞り込みます(v = Bag.pick() のあと if v is not None:)。
isinstance(x, C) の答えは、x の宣言から決まります。
int の引数に bool を渡す呼び出しや、float の引数に int を渡す呼び出しは、書いた場所で受け付けません。
Python はその値をそのまま持つので、表示や isinstance が宣言と違う答えを返すからです。
この決まりがあるので、どちらの実行でも値は宣言どおりになります。
答えはどちらの実行よりも前に分かり、その答えで通らないと決まった分岐は翻訳もしません。
型チェッカーがその分岐を読まないのと同じです。
def weigh(w: int) -> int:
if not isinstance(w, int) or isinstance(w, bool):
raise ValueError("not a whole number") # 通らないと決まっている。翻訳しない
return w // 1000
宣言で決まらない場合は、理由を挙げて受け付けません。
float に対する isinstance(Python は float の場所に int も入れられます)、Optional に対するもの(先に絞り込みます)、NewType を使ったもの(Python が TypeError を出します)です。
if と else の両方で代入したローカルは、Python と同じように分岐の後でも読めます。
elif が続く場合も、出口をすべて数えます。
raise、return、break、continue で終わる分岐は、分岐の後で読む所まで届かないので、代入は要りません。
def judge():
n = score()
if n > 20:
verdict = "high"
elif n >= 0:
verdict = "low"
else:
raise ValueError("a negative score")
grade.set(verdict) # 分岐の後で読める
片方の分岐でしか代入していないローカルを後で読むことは断られます(その分岐が実行されなければ、Python では NameError になる形だからです)。
Optional の絞り込みは walrus で書きます。
Optional のローカルや引数は、Python がそこに値があると分かる場所で、同じ名前のまま絞り込まれます。
if の分岐の中、and や or の後ろに続く部分、条件式で値があると分かるほうの枝です。
ガードの後ろでも絞り込まれます。if x is None: の分岐が return、raise、break、continue で抜けるなら、ブロックの残りでは x を値として読めます。
best: int | None = None
for v in scores():
if best is None or v > best: # `best is None` の後ろでは best は int
best = v
top = best if best is not None else 0
関数を値として扱う
関数はここでは値です。
ローカル変数に入れたラムダ、入れ子の def、store に持たせるコールバック、別の関数が受け取る引数として使えます。
型は Callable で書き、コンパイルされるクロージャはその注釈から作られます。
本体から型を読み取ることはないので、クロージャは必ず「何を受け取るか」を書いてある場所に置きます。
from typing import Callable
@store
class Pipeline:
n: int = 1
step: Callable[[int], int] = lambda x: x + 1 # コールバックのフィールド
def advance(self) -> None:
f = self.step
self.n = f(self.n)
def harder(self) -> None:
self.step = lambda x: x * x # 別のものに差し替える
def apply(self, g: Callable[[int], int]) -> None:
self.n = g(self.n) # 引数として受け取る
def offset() -> None:
base = Pipeline.n
def add(x: int) -> int: # 入れ子の def
return x + base
Pipeline.n = add(10)
def doubled() -> None:
twice: Callable[[int], int] = lambda x: x * 2 # ローカル変数
total.set(sum(list(map(twice, xs())))) # map が呼び出す
xs.set(sorted(xs(), key=twice)) # key= も呼び出す
クロージャは、作られた場所で値を捕まえます。
上の base は、その時点でそこにあった数です。
Python は変数のほうを捕まえるので、二つの場合に食い違いが起こります。
どちらも名前を挙げて断ります。
クロージャがすでに捕まえたローカル変数への書き込みと、ループ変数を捕まえたクロージャがその回を超えて生き残ることです。
同じ回の中で呼ぶだけなら、どちらの実行も同じ値を読むので通ります。
クロージャの中で State やフィールドを読むと、読むのはクロージャが動く時点です。
これは Python でも同じです。
ビューからクロージャは呼べません。 画面を組み立てるのは読むだけの作業で、クロージャは書き換えられるからです。 ハンドラから呼んで、答えを State に置いてください。
算術
Python の算術演算子はそのまま使えます。
+、-、* に加えて、/(結果は常に float)、//(負の無限大方向への切り捨て)、%(結果は除数の符号)、** も使えます。
どれもPython と同じ結果を返すようにコンパイルされます。
ゼロ除算やオーバーフローが起きると、その文だけが中断されます。
開発中は Python の例外として見え、リリース版でも同じ文で止まります(どちらもアプリは落ちません)。
int ** int の指数は非負のリテラルで書きます。
指数が負だと、結果の型が実行時に変わってしまうからです(負の指数は、どちらかを float にすれば書けます)。
失敗しうる / // % ** はハンドラの中で計算し、ビューには結果を渡します。
int と float は Python と同じに混ぜられます。x + 1 は float で、比較は int が何であっても正確です(x < 0、level() > count()、0 <= x < n)。
int と float の max と min は受け付けません。Python は選んだほうをそのまま返すので、値しだいで int にも float にもなるからです。
and、or、not は条件の中でそのまま使えます。
bool の値としても使えます(both.set(hot() and not cold()))。
bool 以外の値を and / or でつなぐ書き方は断られます(Python では結果が真偽値ではなく、どちらかのオペランドそのものになるためです)。
文字列
文字列は Python と同じように扱えます。
メソッド、長さ、添字とスライス、in、型変換が使えます。
name.set(raw().strip().upper())
parts.set(raw().split(","))
name.set(", ".join(parts()))
first.set(raw()[0] + raw()[1:4]) # コードポイントとスライス
n.set(len(raw()) + raw().find("a"))
if "ada" in raw().lower():
tag.set("found")
n.set(int("42") + int(2.5) + round(2.5)) # round は Python と同じ偶数丸め
算術と同じく、ここは二つの実行が別のコードを使う場所です。
開発中に走るのは CPython のメソッド、コンパイル後に走るのは同じ答えを返すように書いた Rust の双子です。
失敗の仕方まで同じで、int("x") はどちらの実行でもその文を中断します。
その二つを突き合わせるのがゲートです。
書式指定も Python のものがそのまま使え、ビューでもハンドラでも同じように書けます。
text(f"{total():,}") # 1,234,567
text(f"{ratio():.1%}") # 12.5%
text(f"{name():>10}") # 10 桁で右寄せ
text(f"{value():.2e}") # 1.50e+00
リスト、チャート、仮想化リスト
リストへの追加は「連結して置き直す」形で書きます。 リリース版では 1 要素の追記にコンパイルされるので、コピーのコストはありません。
Python のリスト操作はハンドラの中でそのまま使えます。
in、スライス、sorted / min / max / sum、内包表記、enumerate と zip、step 付きの range、二つのリストの連結です。
ローカルのリストは、注釈で要素の型を書きます(コンパイル時の型はこの注釈から決まります)。
out: list[str] = []
for i, s in enumerate(items()):
if s != "":
out = out + [f"{i}: {s}"]
items.set(sorted(out))
best.set(max(scores()))
関数の中で作ったローカルのリストは、同じリストを指す名前がほかになければ、out.append(x) でその場で伸ばせます。
Python では同じリストを指す別の名前からも伸びたのが見えますが、コンパイル済みのリストは値です。
両方の実行が一致するのは、別の名前がない場合に限られます。
つまり、どの読みも中を見るだけか(len、添字、in、そのリストを伸ばさない for、sorted、", ".join(...))、関数を終える return で返すだけの場合です。
呼び出しの引数、別の名前、フィールド、クロージャにリストを渡した後の append は受け付けず、渡した場所を示します。
out = out + [x] はどちらの実行でも新しいリストを作るので、どこでも使えます。
Optional のリスト(list[int | None])も、Python と同じに書き、None や値で伸ばし、たどれます。要素は Optional で、ほかの Optional と同じように絞り込めます。
要素が何かを問わない操作(in、スライス、+、[::-1])は、要素が値クラスでもタプルでも、どんなリストにも使えます。
比べる操作には何を比べるかが要るので、sorted と min と max は key= を取り、sorted は reverse= も取ります。
key= に渡すのは、要素を一つ取るラムダか、そういうヘルパの名前です。
並べ替えは reverse=True でも安定で、キーが等しい要素は入ってきた順のまま残ります。
by_score = sorted(players(), key=lambda p: p.score, reverse=True)
leader = max(players(), key=lambda p: p.score)
names = [p.name for p in players()]
newest = entries()[::-1]
reversed(xs) は Python ではイテレータなので、for で後ろから回すのに使います。
リストがほしいところでは、Python でもリストになる xs[::-1] を書きます。
添字は Python と同じ意味で読めます。 負の添字は後ろから数え、範囲を外れた添字はどちらの実行でもその文を中断します。
first.set(names()[0]) # 状態を読んで添字を引く
tail.set(names()[-1]) # 最後の要素(短すぎればその文が中断)
for i in range(len(Cart.items)):
Cart.items[i] = "-" # ストアの中なら `self.xs[i]` も同じ
チャートは float か int のリストを描きます。
values: State[list[float]] = State([])
line_chart(values(), height=120.0)
bar_chart(Metrics.svc_reqs, labels=Metrics.svc_names, height=100.0)
bar_chart(Books.profit, labels=Books.months, axis=True) # 負の月は 0 の線の下に垂れる
line_chart(series=Traffic.lines, colors=["accent", "#f38ba8"], axis=True)
範囲はデータ全体から決まり、常に 0 を含みます。
そのため、負の値は 0 の線の下に垂れます。
min= / max= を与えれば、範囲を固定できます。
axis=True で目盛りのラベルとグリッド線が付きます。
チャートにポインタを載せると、その下の値が読めます。棒や点のラベル(ラベルがなければ #3 のような番号)と、系列ごとの数値です。
スクリプトでは hover:<i>(i 番目の点。n 番目のチャートなら hover@n:<i>、棒グラフと折れ線グラフを通して数えます)でポインタを載せ、hover: で外します。ダンプにその読み取りが載るので、ホバーで見えるものもクリックと同じように確かめられます。
series= は list[list[float]] のフィールドを取り、線やバーの組を複数描きます。
colors= は系列ごとの色、color= は単一系列の色です(demo/charts.py)。
progress(value) はトラックを埋めます。
width= / height= が大きさ、label= が見出しです。
indeterminate=True にすると、値の代わりに区画が往復します。
どれだけかかるか分からない作業のための表示です。
行数の多いリストは list_view に渡します。
仮想化されていて、行を作る関数 row(i) は見えている範囲についてだけ呼ばれます(10 万行でも十数回)。
def row(i):
return text(items()[i])
list_view(len(items()), row, item_height=22.0, height=200.0)
list_view(len(items()), row, item_height=22.0, grow=1.0) # 親の残り高さを埋める
selected= と on_select は、表と同じようにリストでも使えます。
選ばれている行はアプリが持つ番号で、行をクリックすると、選択が勝手に動くのではなく、アプリに番号を動かしてくれと頼みます。
スクリプトは、行が表示している文字で行を選びます。
行の中身は行を作る関数が返したものなので、その行のどこかにある最初のテキストが目印になります。
on_select の無いリストはただのリストで、select: のステップはそれを数えません。
list_view(len(names()), line, item_height=28.0, height=180.0,
selected=picked(), on_select=picked.set, scroll_to=picked())
scroll_to= は、見えるところに出したい行の番号です。
スクロールの位置そのものは、スクロールしている人のものなので、アプリが言えるのはこの一言だけです。
リストは番号が変わったときにだけ従い、それ以外では表示位置に触りません。
誰かがスクロールしたリストが、画面を組み直すたびに引き戻されることはありません。
-1 は何も頼まないという意味です。
スクリプトにスクロールのステップが無いのも同じ理由です。
ダンプに出るのは頼んだ番号で、二つの実行が突き合わされるのもそれだからです。
表は、ヘッダーと列トラックを持つ list_view です。
table(columns, count, row) は、見えている行についてだけ row(i) を呼びます。
行を作る関数は、セルを列ごとに一つずつ並べた row を返します。
widths= はトラックの比率です。
selected= はその番号の行を塗り、on_select はクリックされた行のインデックスを受け取ります。
sort= / descending= はヘッダーの矢印を描き、on_sort はクリックされた列のインデックスを受け取ります。
並べ替えそのものは、アプリが自分のリストに対して行います。
スクリプトでは select:<先頭セル> で行を選び、click:<列名> でソートします(demo/roster.py)。
def cells(i: int):
return row(text(Roster.names[i]), text(f"{Roster.scores[i]}"))
table(["member", "score"], len(Roster.names), cells, widths=[2.0, 1.0],
selected=Roster.sel, on_select=Roster.pick,
sort=Roster.sort_col, descending=Roster.desc, on_sort=Roster.sort_by, grow=1.0)
行番号は int としてそのまま使えます。 テキストの中でも、条件でも、その行のハンドラの中でも読めます。
def line(i):
with row(spacing=6):
text(f"{i + 1}. {items()[i]}")
if i == Sel.idx:
text("*")
button("delete", on_click=lambda: Sel.drop(i))
list_view(len(items()), line, item_height=24.0, height=200.0)
キャンバス
キャンバスは、仮想的なピクセルを一つずつ描いていく格子です。
width と height がそのピクセル数で、scale は仮想の1ピクセルが論理ピクセル何個分かを表します。
canvas(160, 120, scale=4) は画面上で 640x480 を占めます。
描画命令はブロックの中に並べます。
with canvas(160, 120, scale=4, background=0, palette=Game.palette):
rect(Game.x, Game.y, 8, 8, 7)
circle(30, 20, 4, 12)
pixel_text(4, 4, f"SCORE {Game.score}", 7)
色はすべて数値です。
アプリが宣言した palette(16進の色のリスト)の何番目かを指す番号です。
色をパレットの番号で指すのは、ドット絵を扱う道具に共通のやり方です。
そうした道具のために書かれた描画コードは、番号を書き換えずにそのまま移せます。
範囲を越えた番号は最後の色で描きます。
見えなくなるより、間違った色が見えるほうが直せるからです。
palette が空のキャンバスはマゼンタで描きます。
命令は pixel、line、rect、rect_outline、circle、circle_outline、triangle、triangle_outline、sprite、pixel_text です。
座標は整数だけです。
ピクセルの格子に半分のピクセルはないので、浮動小数点数は断り、int(...) を書くように言います。
sprite(x, y, source, u, v, w, h) は PNG の一部を切り出して置きます。
colkey= は写さない色の番号で、flip_x= と flip_y= は左右と上下の反転です。
pixel_text はキャンバス自身が持つ 4x6 のフォントで、ピクセルの格子の上に文字を書きます。
キャンバスの中の for は普通のループです。
ループの本体が描いたものが、その場所でフレームに加わります。
with canvas(160, 120, scale=4, palette=Game.palette):
for e in Game.enemies:
sprite(e.x, e.y, "assets/sheet.png", 0, 16, 8, 8, colkey=0)
回せるのはビューが直接読めるリスト(State のセル、ストアのフィールド、モデル自身のフィールド)で、その要素はスカラーか value クラスです。
for i, e in enumerate(...) と書けば、要素と一緒に添字も受け取れます。
for i in range(2): も書けます。
こちらはその場で展開されるので、範囲には数値をそのまま書きます(64 個まで)。
展開した結果が、そのまま子要素の並びになるからです。
これらのループはキャンバスに限らず、どのコンテナの中でも同じように書けます。
描画命令は要素ではありません。
共通のプロパティは一つも取れません。
キャンバスの中のものはクリックできず、アクセシビリティのツリーではキャンバス全体が1枚の画像です。
何を描いているかを伝える手段は a11y_label= だけです。
ダンプに出るのはフレームそのもので、1命令が1行になります。
だから yokan gate は、両方の実行が描こうとした絵を比べられます。
Canvas(160x120, scale=4, bg=#000000)[
Rect(56, 100, 8, 8, #eeeeee)
PixelText(4, 4, "SCORE 1250", #eeeeee)
]
辞書
読みは .get、書きはキー単位、数えるのは len で、回し方は Python の辞書と同じです。
キーには str なら何でも書けます(リテラル、状態の読み、ループ変数)。
prices["cherry"] = 200 # キー単位の書き込み
picked.set(prices().get("apple", -1)) # 読み: 無いときは default
if "cherry" in prices(): ... # 所属
len(prices()) # 件数
def scan():
for k in prices(): # 挿入順、Python が回るのと同じ順
last.set(k)
for v in prices().values(): # 同じ順
total.set(total() + v)
for k in sorted(prices()): # キー順で回りたいとき
first.set(k)
コンパイル後の辞書はキーを入れた順を覚えているので、回すと Python と同じ順に並びます。
辞書はローカル変数にも置けます。
型を書いておくのが条件で、counts: dict[str, int] = {} と書いてから counts[k] = counts.get(k, 0) + 1 のように使います。
d[k] をそのまま読むと、無いキーのとき Python は KeyError を投げます。
だから書き方は二つです。
無いときに何を返すかを書く .get(key, default) か、Python と同じようにキーの不在を捕まえる try です。
try が捕まえるのは、名前に束ねた読みそのものです。
try:
gold = counts["gold"]
found.set(f"gold {gold}")
except KeyError as e:
found.set(f"no {e}") # e は Python と同じく 'gold' と読めます
.items() はキーと値の対で回ります。
こちらの順序も挿入順です。
リストを値に持つ辞書でグループ分けができます。
groups: State[dict[str, list[str]]] = State({})
for w in words():
groups[w[0]] = groups().get(w[0], []) + [w]
タプル
タプルは、いくつかの値を一つにまとめたものです。 Python と同じ書き方で書き、同じ読み方で読みます。
pair: State[tuple[str, int]] = State(("momo", 4))
rows: State[list[tuple[str, int]]] = State([])
def measure(word: str) -> tuple[str, int]:
return (word.upper(), len(word))
def scan():
label, n = measure("hello") # 分解
first = pair()[0] # 位置はリテラル
whole, rest = divmod(n, 3)
for name, count in rows(): # 行ごとに対
total.set(total() + count)
for key, value in prices().items(): # 辞書も対で回る
seen.set(seen() + key)
部分はそれぞれ自分の型を持つので、位置はリテラルで書きます。 計算した位置だと型が一つに決まりません。 部分は二つ以上です。 同じ形を、state にもフィールドにもリストの要素にも、引数にも返り値にも置けます。
NamedTuple は、部分に名前も付いたタプルです。
Python でもそういうものとして定義されています。
Value クラスを置ける場所ならどこにでも置け、タプルとしても読めます。
名前でも、リテラルの位置でも、分解でも、対のループでも読めます。
from typing import NamedTuple
class Row(NamedTuple):
"""One row of the report."""
name: str
qty: int
note: str = ""
r = Row("pens", 3)
name, qty, note = r # 分解
same = r[1] == r.qty # 位置でも名前でも
bigger = r._replace(qty=r.qty * 2) # ほかのフィールドはそのままの新しい値
total = 0
for label, count, _ in rows(): # リストなら部分ごとに
total += count
同じクラスのもの同士は == で比べられます。
二つのタプルの大小比較、スライス、連結は、まだ方言にありません(どれも部分ごとに作ったり比べたりする操作です)。
タプルの文字列化もまだです。
Python は repr(Row(name='pens', qty=3, note=''))を書くので、見せたい部分を表示してください。