以前のコンセプトでは、ローカルラベルも関数も、section .textのような実行可能なコードが入ったセクションの中の、単なるアドレスにすぎないという話をしました。
実際、関数は他のメモリアドレスと同じように扱うことができます。つまり、レジスタに読み込んだり、受け渡したり、メモリに保存したりできるのです。
また、callやjmpを使って、レジスタやメモリに保存された関数へ実行を移すこともできます。
section .text
sum_op:
lea rax, [rdi + rsi] ; loads the sum rdi + rsi into rax
ret
apply_sum:
lea rax, [rel sum_op]
jmp rax ; tail call
値として受け渡される関数アドレスのことをサンクと呼びます。 サンクは、アセンブリにおける高階プログラミング、つまり他のコードを操作するコードの構成要素です。
関数アドレスは、メモリに保存して後から取り出すこともできます。
section .bss
cached_fn resq 1
section .text
save_op:
mov qword [rel cached_fn], rdi
ret
apply_op:
; arguments are already set up according to the ABI
jmp qword [rel cached_fn] ; tail call
save_opは、受け取った関数アドレスをcached_fnに書き込みます。
この値はsave_opが戻ったあとも残るので、その後にapply_opを呼び出すと、最後に保存されたアドレスへテールジャンプします。
これにより、実行時にapply_opが呼び出す関数を切り替えられます。
関数アドレスを配列に保存すると、何らかのインデックスに応じて異なる関数を選べるようになります。インデックスは、実行時の条件によって決まることもあります。 これをディスパッチテーブルと呼びます。
section .data
dispatch_table dq add_op, sub_op, mul_op
section .text
dispatch:
; this function takes two arguments in rdi and rsi, and an index in rdx
; it then applies the function corresponding to the index in rdx to the arguments
lea rax, [rel dispatch_table]
jmp qword [rax + 8*rdx] ; tail-call the function address for the index
呼び出しの合間に永続するメモリを読み書きするサンクは、それまでの状態によって振る舞いが変わることがあります。 その結果は、引数だけでは決まらないことがあります。
たとえば、関数を受け取り、現在のカウントを渡して呼び出し、呼び出すたびにカウントを進める_カウンター_を考えてみましょう。
section .data
count dq 0
section .text
tick:
mov rax, rdi ; saves the function address
mov rdi, [rel count] ; loads the current count as the function's argument
inc qword [rel count] ; advances the count
jmp rax ; tail-calls the function
tickは、与えられた関数を現在のカウントを引数として呼び出し、その後でカウントを進めます。
つまり、最初にtick(square)を呼び出すとsquare(0)が呼び出され、次にtick(square)を呼び出すとsquare(1)、その次はsquare(2)、というふうになります。
もう一つの例は、_遅延計算_です。
section .bss
captured_fn resq 1
argument resq 1
section .text
delay:
mov qword [rel captured_fn], rdi ; saves the function
mov qword [rel argument], rsi ; saves the argument
lea rax, [rel invoke] ; returns the `invoke` function
ret
invoke:
mov rdi, qword [rel argument] ; loads the saved argument into `rdi`
jmp qword [rel captured_fn] ; tail-calls the saved function
delayは関数と値を受け取り、それらを保存してinvokeを返します。
invokeが呼び出されると、保存しておいた関数を、保存しておいた引数で実行します。
高水準言語でよく見られるパターンの多く、たとえばコールバック、仮想メソッド、ジェネレーター、カリー化、関数合成などは、永続的な状態と組み合わせたサンクの上に成り立っています。
小さな村の銀行で簿記係をしています。 顧客はそれぞれ口座を持っており、その残高は台帳に記録します。 一年を通して、これらの残高に取引が適用されます。利息が加算され、手数料が差し引かれ、ボーナスが支払われ、罰金が請求されます。 どの取引も、1つの残高を受け取って新しい残高を生み出します。
4つのタスクがあります。
この演習に登場するすべてのサンク(取引とガード)は、次のような関数だと想定してかまいません。
窓口係は、一日の始めに新しい取引を教わり、顧客が来たときに後から適用できるように書き留めておきます。
2つの関数を定義します。
remember_transactionは取引を受け取り、メモリーに保存します。apply_rememberedは残高を受け取り、前に保存した取引をそれに適用します。例として、add_interestが5単位の利息を加算する取引だとします。
remember_transaction(add_interest);
apply_remembered(100);
// => 105
remember_transaction(service_fee);
apply_remembered(100);
// => 98 (assuming service_fee deducts 2)
remember_transactionについて:
apply_rememberedについて:
銀行の手引きには、よく使う取引の一覧が_ディスパッチテーブル_に保存されています。 各支店は一覧のコピーをそれぞれ保持しており、地域のポリシーに応じて異なる取引を登録できます。
呼び出し側が渡すディスパッチテーブルを操作する2つの関数を定義します。
register_transactionは、ディスパッチテーブルのメモリーアドレス、インデックス、取引を受け取ります。
そして、その取引をテーブル内の指定されたインデックスに保存します。select_transactionは、ディスパッチテーブルのメモリーアドレス、インデックス、残高を受け取ります。
指定されたインデックスの取引を参照し、それを残高に適用して、新しい残高を返します。select_transactionは、参照した取引に1回の間接末尾呼び出しで到達するようにします。
例として、manualが4つの空きスロットを持つディスパッチテーブルのメモリーアドレスだとします。
register_transaction(manual, 0, monthly_interest);
register_transaction(manual, 1, service_fee);
select_transaction(manual, 0, 100);
// applies monthly_interest to 100
select_transaction(manual, 1, 100);
// applies service_fee to 100
register_transactionについて:
select_transactionについて:
月末に、顧客の口座が照合されます。 その月に行われたすべての取引が、開始時の残高に次々と適用され、その結果が新しい残高になります。
関数process_statementを定義します。これは、開始時の残高、取引の配列のメモリーアドレス、配列内の取引の数を受け取ります。
それぞれの取引を順番に、現在の残高に適用し、その結果を次の取引の残高として使います。
最終的な残高が返されます。
擬似コードでは、process_statement(balance, transactions, n)は次のように計算します。
for each transaction in transactions:
balance = transaction(balance)
return balance
例として、transactionsが、add_interest、service_fee、add_interestの順に並んだ取引を含む配列のメモリーアドレスであり、add_interestが5を加算し、service_feeが2を差し引くものだとします。
process_statement(100, transactions, 3);
// add_interest(100) = 105
// service_fee(105) = 103
// add_interest(103) = 108
// => 108
第1引数は64ビットの非負整数です。 第2引数は、取引の配列のメモリーアドレスです。 第3引数は64ビットの非負整数(配列の長さ)です。 戻り値は64ビットの非負整数です。
銀行のポリシーでは、特定の取引を確定する前にチェックすることが求められています。 ガードとは、提案された残高を調べ、それが受け入れ可能かどうかを判断する関数です。 このガード関数は、承認する場合は非ゼロの値を返し、拒否する場合はゼロを返します。
process_with_guardを定義します。これは、開始時の残高、取引の配列のメモリーアドレス、配列内の取引の数、ガード関数を受け取ります。
それぞれの取引について、順番に次のようにします。
すべての取引を処理したあと、最終的な残高と、承認された取引の数を返します。
擬似コードでは、process_with_guard(balance, transactions, n, guard)は次のように計算します。
approved = 0
for each transaction in transactions:
tentative = transaction(balance)
if guard(tentative) is non-zero:
balance = tentative
approved = approved + 1
return balance, approved
たとえば、次のように仮定します。
add_interestは5を加算する取引で、service_feeは2を差し引く別の取引ですat_least_10は、残高が10以上の場合に非ゼロの値を返すガードですこのとき:
process_with_guard(5, {add_interest, service_fee, add_interest}, 3, at_least_10);
// add_interest(5) = 10; at_least_10(10) != 0;
// => balance = 10, approved = 1
//
// service_fee(10) = 8; at_least_10(8) = 0;
// => balance = 10, approved = 1
//
// add_interest(10) = 15; at_least_10(15) != 0;
// => balance = 15, approved = 2
//
// final balance (15) is returned in rax
// number of approved transactions (2) is returned in rdx
process_with_guardについて:
raxに、承認された取引の数はrdxに返されます。Exercismに登録すれば、22個のコンセプト130個の演習、そして本物の人間によるメンタリングとともに、x86-64 Assemblyを学んでマスターできます。すべて無料です。