Skip to content

Tracing the Source

実行結果をもとにしてコードを書き換えられるようにしたい、とても。

メモ書き

実現可能にしたいコード書き換えは何種類かある。

  • プロパティや引数として渡された値を変更する
    • プロパティの書き換えとか
  • 関数の呼び出しを削除する
    • 多分 schedule() の呼び出しをまるごと消すとかのレベルでいい
  • 関数の呼び出しを追加する
    • schedule() の呼び出しを追加するとか
  • メソッド呼び出しを削除・追加する
    • tween()tween js みたいに記述したい

実現するための方法はいくつかあると思う。

  • コードのASTをパターンマッチして、実質的なJSONに変換して扱う
    • ユーザーが自由に書いたコードはGUIで編集できない
    • 実装は簡単だし、生成するコードやコード編集の自由度は高い
  • 値の出どころのマーカーが付いた値を使ったコードを書かせる
    • Jax, PyTorch とかがやっている
    • JavaScript では演算子のオーバーライドとか弱いので無理
    • 例えば typeof をごまかすこともできない
  • コールスタックを使って追跡する
    • コールスタックは new Error().stack で簡単に取得できる
    • ソースマップを使って原文でのコード位置も得られる
    • 変数代入とかを介する場合、追跡できなくなる
    • コールスタックは10個くらいしか辿れない
    • 今のところ new Error().stack は非標準らしい
  • 実行を追跡する機能の付いた特製インタプリタ上でコードを実行する
    • JSの仕様は膨大だし、つらい
    • 無理
  • 実行の追跡のためのコードをトランスパイル時に追加する
    • 現実的な方法
    • 値をリレーする関数とかがあっても出自を追跡できる
    • コードのパターンマッチより編集は大変そう

いくつか考慮したい場面がある。

  • コード書き換えは直感的に簡単に使えるようにしたい
    • ユーザーがツールを開発するときに呼び出す機能なので
  • 複数のコード書き換えを同時に適用できるようにしたい
    • ユーザーが作れるツールを制限したくないので
    • コード編集がコンフリクトするときは明確にエラー出すようにしたい
  • GUIからの操作が反映される前に、外部のコードエディタでの編集があるかも?
    • 単純なdiffのマージよりも信頼性の高い方法があるはず
    • 例えばASTレベルでマージするとか
    • そもそも頻繁にコードを保存すればいいのでは?
  • tween はキーフレームを打ってGUIから編集できるようにしたい
    • プロパティに普通の値だけじゃなくて Tween も渡せるようにする
    • Tween はキーフレームの追加、削除、編集をコード操作にマッピングできる
    • リンクされた値とかも一緒にして LiveValue とかにすると思う
      • 読み出すたびに値が変わる値
  • ユーザーがデバッグしやすいようにする
    • Chrome DevTools をエンドユーザーにも使ってもらう予定なので
    • ソースマップを使えば普通にデバッグできる、簡単なコード書き換えをする
    • 将来的には、アプリ内エディタにREPL強化機能をつけたい
  • コード書き換えはブラウザ内で行いたい
    • 高度な書き換えではAST編集関数をユーザー定義部分から渡すことになると思うので
  • 逆算できないとわかった段階で追跡グラフを切る?
    • メモリ削減になるけども
    • エディタ上でのデバッグ・コード理解の支援には機能が足りなくなるけども

メモ書き・まとめ

  • 満たしてほしいこと
    • マーカーを付けない場合に実行できるコードはすべて実行できる
      • あまり使わない・迂回できる機能なら、最悪サポートしないでもいい
      • 使い勝手は悪いけど、マーカー無しで実行する関数にくくりだせば動くので
    • マーカーがついている場合には、そのマーカーは正しいコード位置を指している
    • 追跡できない値があるのは問題ないが、手動で追跡をアシストできるならなお良い
  • 実現方法の概要
    • トランスパイル時に追跡用のコードを追加する
    • DevTools と相性が悪いトランスパイルは避ける
    • コード書き換えはブラウザ内で行う
    • トランスパイル済みコードにはコード原文を埋め込む
      • DevTools での検索ノイズになるので base64(+gzip) しておく
    • 例外が起こりうる箇所や式の評価順序を考慮してコード変換する
  • 値と実行の追跡
    • リテラルにはコード位置のマーカーを追加
      • オブジェクトの場合、多段的にマーカーを追加?
      • JSXの場合はマーカーを使って新しい属性の追加もしたい
    • 関数とメソッドの呼び出し・プロパティ書き換えの追跡もする
      • 呼び出しのコード位置のマーカーを追加
      • マーカーを受けとれる関数などには引数をマーカーごと渡す
  • パターンマッチ
    • コード位置とパターンを指定してマッチする
    • コード位置から上に遡りつつ、そのAST全体がマッチするかを確認していく
    • マッチしたら、マッチした部分のコード位置とプレースホルダーが返る
      • プレースホルダーは、コード位置とASTが得られる
  • コードの書き換え
    • assert : コード位置を指定して書き換えをロックする
      • すでに書き換えられた部分の場合、エラーを出す
    • modify : コード位置を指定して書き換える
      • すでに assert された部分の場合、エラーを出す
      • すでに書き換えられた部分の場合、エラーを出すかも? : 暗黙の assert
      • リテラル、そのサブツリーの書き換え
      • 関数呼び出しの追加・削除
      • メソッド呼び出しの追加・削除
      • 追加の場合は、コード位置と before/after を指定
    • persist : コードを保存する
  • ファイルシステム
    • 読み込み中のコードファイルが書き換えられた場合、エディタは通知される
    • persist は最新のファイルに対して行われる
      • assert された部分が書き換えられていた場合、 persist は失敗する
  • アンドゥ・リドゥ
    • 編集エントリー : apply(dryRun?) + revert(dryRun?)
    • 編集エントリーの一種として、コード書き換えを保存する
    • assert, modify をまとめて編集エントリーとする
  • 使われ方
    • コンポーネントは @property を使って LiveValue, TracerValue に対応する
    • tween()Tween implements LiveValue をプロパティに設定する
    • Tween はコード書き換えや実行の集計結果をまとめて、キーフレーム編集を実現する
    • Anisketch はトップレベルの schedule() を簡単に一覧・追加・削除できるようにする
  • 追加でできること
    • 演算を追跡して、計算途中の値を確認することとかできる
    • コードの書き換えに使うだけじゃなくて、デバッグやコード理解のためにも役立ちそう
  • 初期の実装
    • 網羅的な実行追跡は、ひとまず後回しにする

コードメモ 1

暗黙の引数を渡す
let ctx
function a() {
let imp = read(a)
console.log(imp) // arguments.callee?
// ... function body
}
function b() {
a()
}
function read(fn) {
if (ctx.fn === fn) {
let imp = ctx.imp
ctx = undefined
return imp
}
}
// これはうまくいかない : 引数の中で ctx を書き換えられてしまうとき
// function call(fn, imp) {
// ctx = { fn, imp }
// fn
// }
function call(fn, imp) {
return (...args) => {
ctx = { fn, imp }
return fn(...args)
}
}
call(a, 'hello')() // -> hello
call(b, 'world')() // -> (nothing)

コードメモ 2

懸念: コールスタックに余計なものが挟まってデバッグしにくくなるかも

コード書き換えの例
circle.x = 100
circle.y = tween(0, 10 * 10)
circle.remove()
debug({ a: 1, b: 1 + 1 })
// ↓ //
const file = Symbol(import.meta.url)
const [setProp, callProp, call, value] = tracingApi(file)
setSource(file, import.meta.url, 'circle.x = 100')
setProp(['L1,C10'], circle, 'x', value('L1,C12', 100))
setProp('L2,C10', circle, 'y', call('L2,C17', tween, value('L2,C18', 0), value(null, 10 * 10)))
callProp('L3,C14', circle, 'remove')
call('L4,C6', debug, value('L4,C7', { a: 1, b: 1 + 1 })) // 実際のプロパティ書き換え時に改めてリテラルかどうか確認する
// 初期段階では、関数呼び出しとプロパティセット、それらの引数だけ書き換える
// setProp, callProp, call では、呼ばれる関数がマーカーに対応しているかを確認して、
// 1. 対応している場合は、マーカーごと渡す
// 2. 対応していない場合は、マーカーを取り払った引数を渡す
// 最終的には、変数の代入や各種演算子や関数定義をまたいだ追跡もできるようにする

コードメモ 3

値が追跡できるかどうかはおいておいて、少なくとも実行時の挙動は変わらないようにしたい。

オブジェクトが絡んできたとき、マーカーを取り払うためには再帰的に処理する必要があるけど、循環参照とかがあり得ることを考えると無謀では?

うまくいかないかもしれない例 1
let o = {}
o.x = 100
console.log(o)
// 追跡可能にするためには o.x はマーカー付き値オブジェクトじゃないといけない
// ということは console.log にわたすときには再帰的にマーカーを取り払う必要がある
// 例えば o.x = o (ツリー構造とかでよくある) とかがあったら、マーカーを取るのも大変
// 加えて、マーカーなしの値に対する副作用をマーカー付きの値に反映する方法があるかもわからない
うまくいかないかもしれない例 2
let o = {}
o['x' + 'y'] = 100
console.log(o)

少なくとも普通の実行は成功するように、マーカー付きの値に演算を適用するより、マーカーと値を分けておいたほうがいいかもしれない?

let a = b + c
d[e] = 1
d[e] += 1
let [f, ...g] = h
let { i = (j = fn(j)) } = k
// ↓ //
let [a, _a] = op('+', [b, _b], [c, _c])
setProp('=', [d, _d], [e, _e], value(1))
setProp('+=', [d, _d], [e, _e], value(1))
let [[f, ...g], [_f, ..._g]] = [h, _h] // トラッカーは Proxy でくるんでおく必要がありそう
let [{ i = (j = fn(j)) }, { _i = ___ }] = [k, _k] // どうする?
// // //
// 例えば、 babel を使うと次のコードになる
let _k$i = k.i
let i = _k$i === undefined ? (j = fn(j)) : _k$i
// これなら普通に変換できそう
let [_k$i, __k$i] = [k.i, _k.i]
let [i, _i] = ___ // 三項演算子、どうする?あとは &&, ||, ?? もどうする?
let out = fn() ? f1() : f2()
// これだったら、次のように変換できる
let [_0, __0] = call([fn, _fn])
let [out, _out] = _0 ? mark('?:true', call([f1, _f1])) : mark('?:false', call([f2, _f2]))
// // //
fn(a, ...b)
{a: f1(), b: f2()}
// これだったら、次のように変換できる、たぶん
call([fn, _fn], [a, _a], rest([...b], _b))
([a, _a] = call([f1, _f1]), [b, _b] = call([f2, _f2]), [{a, b}, {_a, _b}])

再考 1

マーカーと値の変数を分けるのは、コード変換が複雑になりそうなのでできれば避けたい。

考えてみると、マーカーを再帰的に取り払うのが簡単なら別に問題はなくなるはず。

なので、 Tracer { value, trace } な感じで、値は値だけで完結した構造、マーカーはマーカーだけで完結した構造で保持するような Tracer にすれば問題ないはず。

import { add } from 'math'
const a = add(1, 2)
let o = {}
o.x = 100
console.log(o, o.x)
let [f, ...g] = h
let { i = (j = fn(j)) } = k
fn(a, ...b)
{a: f1(), b: f2()}
// ↓ //
const file = Symbol(import.meta.url)
const t = tracingApi(file)
setSource(file, import.meta.url, '<source code>')
import { _add } from 'math'
const add = t.external('<pos>', _add)
const a = t.call('<pos>', add, t.value('<pos>', 1), t.value('<pos>', 2))
let o = t.object('<pos>', {})
t.setProp('<pos>', '=', o, 'x', t.value('<pos>', 100))
t.call('<pos>', t.external('<pos>', console.log), t.value('<pos>', o), t.getProp('<pos>', o, 'x'))
let [f, ...g] = h
let { i = (j = t.call('<pos>', fn, j)) } = k
t.call('<pos>', fn, a, ...b)
t.object('<pos>', { a: t.call('<pos>', f1), b: t.call('<pos>', f2) })

こんなイメージ。 add, aTracer になって、デバッガーを使うときでも value を見たり、出自を追跡したりできるから使いやすさも問題ないと思う。

プロパティの参照は、 Proxy を使ってハンドルするかも、もしくは tracing.getProp とかを用意するかも。 (rest, spread syntax が動くようにするために)