はじめに
Praat Scriptは今年で25周年を迎え、メジャーバージョンも現在6系です。一方、後方互換を重視していて、(今とは似ても似つかない)過去の文法のコードもちゃんと動きます。
新旧の文法が混在する...そう、Praat Scriptは黒魔術の宝庫でもあります。
本記事では、Praat Scriptの文法を悪用しメタプログラミングする方法をご紹介します。実用性、可読性は保証しません1。実用的なテクニックについては以前の記事をご覧ください。
Praat Scriptシリーズ一覧
- Praat Script基礎文法最速マスター2022
- Effective Praat Script
- 悪用厳禁?Praat Scriptの黒魔術、謎文法 (当記事)
- Praat Scriptでゲームを作ってみよう
すべての元凶「変数置換」
文字列変数をシングルクォーテーションでくくる( 'x$'
)と、中身をコードとして展開することができます。
(イメージとしてはマクロの実行時版です)
s$ = "1 + 2"
# 展開した結果の `appendInfoLine: 1 + 2` が評価される
appendInfoLine: 's$' ; 3
そしてこの展開、ASTに一切関係なく任意の文字列を動的にコードに埋め込めます。激アツですね。
生まれた経緯
もちろんこの構文はメタプログラミングのためのものではありません
古い文法では、引数は ""
で括らなくても文字列リテラルとして扱われました。
# 今でいう `appendInfoLine: "foo"`
printline foo
そのままでは変数を渡せないので、変数置換 ''
という構文が導入されたのだと思います2。
s$ = "abc"
# これだと "s$" が表示されてしまう
printline s$
# 変数をコード上に展開することで `echo abc` が評価され、"abc" が表示される
printline 's$'
旧文法、新文法の差分は以下の記事で詳しく紹介されています。
そして、この ''
が使える場所に制限が無いので、激アツ黒魔術になったというわけです。
どこで使える?
行まるごとだろうがトークンの途中だろうが、文のほぼすべての箇所で使えます。
# 行まるごと
line$ = "appendInfoLine: ""Hello, world!"""
'line$' ; Hello, world!
# 予約語
if$ = "if"
'if$' 1 == 1
appendInfoLine: "yes" ; yes
end'if$'
# トークンを跨いでも問題なし!
s$ = "foLine: ""He"
appendIn's$'llo, world!" ; Hello, world!
特に、最後の例のように文構造を完全に無視して置換できるのが強力な点です。
実装を読んでいないので予想ですが、Praat Scriptはコード全体をトークン列、ASTに変換してから評価するのではなく、1行ずつ読み込んでいるように見えます(でないと、前の行の変数の評価が終わった後にその値をトークンの一部に利用することはできないので)。
制約
強力ですが、使えない場所もあります(裏技なのでやむなし)。
制御構造 (for
や procedure
等)
ジャンプ先がの行が認識できずエラーになります。
# 予約語
for$ = "for"
'for$' i from 1 to 3
appendInfoLine: i
end'for$' ; Unmatched endfor
複数行を一度に置換
1行で続けて書いたように認識されるので構文エラーになります。
s$ = "n = 1 + 2" + newline$ + "appendInfoLine: n"
's$' ; expected the end of the formula, but found "appendInfoLine"
多重置換
置換は1度しかできません。
inner$ = "1 + 2"
outer$ = "'" + "inner$" + "'"
appendInfoLine: 'outer$' ; unknown symbol
変数が未定義かつ文字列リテラル内で置換した場合、ただの文字列になる
# s$ は未定義
appendInfoLine: "'s$'" ; 's$' (そのまま表示)
s$ = "foo"
appendInfoLine: "'foo'" ; foo (展開される)
応用例
参照渡しの代わり
公式リファレンスに、「procedureを関数の代わりに使う方法(非推奨)」として紹介されています。
Another way to emulate functions is to use a variable name as an argument:
(procedureで)関数を再現するもう一つの方法として、変数名を引数として使用します。
@squareNumber: 5, "square5"
writeInfoLine: "The square of 5 is ", square5, "."
procedure squareNumber: .number, .squareVariableName$
'.squareVariableName$' = .number ^ 2
endproc
However, this uses variable substitution, a trick better avoided.
しかし、この方法は変数の置換を利用する、避けたほうが良いトリックです。
早期return
「Effective Praat Script」の方でも紹介しましたが、変数置換で endproc
をprocedureに2つ以上書けるようになる(インタープリターを騙す)ので早期returnができます。
endproc$ = "endproc"
procedure isEven: .x
# 整数以外を弾いておく
if floor(.x) != .x
.return = false
# インタープリターをだます!(コード読み込み時は `endproc` は1つしかないので構文エラーにならないが、この行を「評価」したら `endproc` になる)
'endproc$'
endif
if .x mod 2 == 0
.return = false
'endproc$'
endif
.return = true
endproc
@isEven: 2
appendInfoLine: isEven.return ; 0
@isEven: 2.5
appendInfoLine: isEven.return ; 0
@isEven: 5
appendInfoLine: isEven.return ; 1
この後紹介する他の黒魔術でも、色々な所で変数置換が登場します。
再帰
これまでの記事でも、procedureを「名前空間変更を伴うgoto」 と表現してきました。
スコープを抜けても変数が死なないので、普通に書いたら再帰はできません。
procedure factorial: .n
if .n < 0
.return = undefined
else
# factorialをもう一度呼んだ時点で、**同じスコープの** .n が上書きされてしまう
@factorial: .n - 1
.return = .factorial.return * .n
endif
endproc
諦めるのはまだ早い、上書きが困るのであれば元に戻せばいいのです。
clearinfo
endproc$ = "endproc"
procedure factorial: .n
if .n < 0
.return = undefined
'endproc$'
endif
if .n <= 1
.return = 1
'endproc$'
endif
@factorial: .n - 1
# 再帰の外側のスコープの.nの値に戻すためインクリメント
.n += 1
.return = factorial.return * .n
endproc
@factorial(0)
appendInfoLine(factorial.return) ;1
@factorial(1)
appendInfoLine(factorial.return) ;1
@factorial(-1)
appendInfoLine(factorial.return); undefined
@factorial(5)
appendInfoLine(factorial.return) ; 120
@factorial(10)
appendInfoLine(factorial.return); 3628800
~
でQuine
~
で文字列リテラルを作成できます。範囲は行末までで、""
と違ってエスケープ不要です(重要)。
s$ = ~Hello, world! ; コメントアウトとか関係なく全部文字列化
appendInfoLine: s$
s2$ = ~"foo"
appendInfoLine: s2$
Hello, world! ; コメントアウトとか関係なく全部文字列化
"foo"
エスケープ不要なので、Quine(自分自身を出力するコード)に利用できます。
s$ = ~s$ = ...writeInfo(replace$(s$, "...", "~" + s$ + newline$, 1))
writeInfo(replace$(s$, "...", "~" + s$ + newline$, 1))
s$ = ~s$ = ...writeInfo(replace$(s$, "...", "~" + s$ + newline$, 1))
writeInfo(replace$(s$, "...", "~" + s$ + newline$, 1))
Quineの作り方は「あなたの知らない超絶技巧プログラミングの世界」を参考にしました。
;
はコメントアウトとは限らない!
Praat Scriptのparser、かなり複雑そうですね...
# `;` がprocedure名!
procedure ;
appendInfoLine: "a"
endproc
@;: ; a
条件付きgoto
gotoの引数で真偽値を与えると、trutyの場合のみジャンプします。
goto BAR 0 ;falseなので飛ばない
goto FOO 1 ; trueなので飛ぶ
exitScript: 0
label FOO
appendInfoLine: "foooooooooooooo!" ; foooooooooooooo!
exitScript: 0
label BAR
appendInfoLine: "baaaaaaaaaaaaar!"
sendSocketでHTTPリクエスト?
sendSocket
は、TCPソケット通信をするための関数です。なぜか送信専用で受信はできません。一応HTTPリクエストを送り付けることができます(だからどうした)。将来receiveSocketができればPraat Scriptでサーバーが立てられる!(妄想)
clearinfo
# HTTPリクエストべた書き
req$ = "GET / HTTP/1.1'newline$'Host: Praat"
# リクエスト送信
sendsocket localhost:8000", 'req$'
# ちゃんと届いた!
$ python -m http.server 8000
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::ffff:127.0.0.1 - - [11/Apr/2022 21:34:19] "GET / HTTP/1.1" 200 -
~
で疑似ブロック
Rubyのブロックを再現しました。
@times: 4, ~
... appendInfoLine("hello!") \
... appendInfoLine("hello, world!")
hello!
hello, world!
hello!
hello, world!
hello!
hello, world!
hello!
hello, world!
種明かしをすると、~
でブロックとして使いたい文を文字列化して、変数置換で実行しています。
また、行の区切り文字として \
を使用しています(Praat Scriptにこんな文法はありません)。
procedure yield(.blk$)
# replace backslashes to newlines
.blk$ = replace_regex$(.blk$, "\\", newline$, 0)
while .blk$ <> ""
# extract line
.line$ = extractLine$(.blk$, "")
# trim .line$ from .blk$
if index_regex(.blk$, "\n")
.blk$ = replace_regex$(.blk$, "^[^\n]*\n", "", 1)
else
.blk$ = ""
endif
# trim spaces, otherwise an error occurs)
.line$ = replace_regex$(.line$, "(^\s+|\s+$)", "", 0)
# eval .line$
'.line$'
endwhile
endproc
procedure times(.n, .blk$)
for .i to .n
@yield(.blk$)
endfor
endproc
正規表現をもっと使えば each
や map
も作れそうです。
sumOverで1行ループ処理
sumOverの第2引数は、numberに評価される式であればなんでも入れられます。appendInfoLine
は 1
を返すので、1からnまでの整数を1行で出力できます!
sumOver(i to 10, appendInfoLine: i)
# 以下と同じ
for i to 10
appendInfoLine: i
endfor
include+procedureで疑似OOPしたかった...(未完成)
(この項目だけは未完成です)
Praat Scriptにはもちろんインスタンスやメソッドという概念はありません。しかし、procedureが状態を持つことを応用すれば、インスタンスのように使うことができそうです。
とっかかりはいくつか思いついたのですが、苦節五年、残念ながらいまだにうまくいく方法が見つかっていません... 3
できないものを紹介しても仕方がないのですが、読者の方が将来OOPの黒魔術を思いつく際の参考になれば(?)と思い途中経過を公開します。
procedure名に変数置換を使う
procedure名の探索に失敗するのでうまくいきません。
procedure 'self$': .name$, .age
endproc
@person1: "taro", 21 ; person1 not found
どうやらprocedure名は変数置換よりも前に解決されるようです。
includeのファイル名に変数置換を使う
ならば、読み込み時にprocedure名を書き換えてしまえば良いのでは?と思いましたが、includeは変数置換できませんでした。
s$ = "foo.praat"
include 's$' ; cannot open 's$'
実行中にソースコードを書き換える
それなら、 include
でファイル名を差し込んでみるのはどうでしょう? ...
を使えば行中でも改行できるので、procedure名の差し替えたい部分だけ改行して、includeで差し替えるようにします。
# ... の行継続を使って、行の途中をincludeで差し替える
# procedure 'self$': .name$, .age
procedure
include self.praat
...: .name$, .age
endproc
# procedure 'self$'.canDrink:
procedure
include self.praat
....canDrink:
.self$ = "
include self.praat
..."
.return = '.self$'.age >= 20
endproc
# procedure 'self$'.greet:
procedure
include self.praat
....greet:
.self$ = "
include self.praat
..."
appendInfoLine: "I am ", '.self$'.name$, "!"
endproc
試しに self.praat
に適当な名前を入れてみます。
...person1
include person.praat
@person1: "taro", 21
@person1.canDrink:
appendInfoLine: person1.canDrink.return ; 1
@person1.greet: ; I am taro!
それでは、いよいよself.praat
を動的に書き換えてインスタンスを生成...できませんでした。
clearinfo
procedure setSelf: .name$
.fileName$ = defaultDirectory$ + "\" + "self.praat"
writeFileLine: .fileName$, "..." + .name$
endproc
self$ = "hanako"
include person.praat
@hanako: "Hanako", 23 ; procedure hanako not found
include
は評価よりも先に行われるので、実行時に self.praat
を書き換えても手遅れでした。お手上げです
おわりに
しょうもないネタにお付き合いいただきありがとうございました。みなさんもぜひPraat Scriptの新たな可能性を開いてみてはいかがでしょうか?(なお研究のまじめなコードで使っても責任は負いかねますのでご了承ください)
次回はシリーズ最後の記事、UI操作でゲームを作る方法を紹介します!