簿記

簿記

学習演習

はじめに

サンク

以前のコンセプトでは、ローカルラベルも関数も、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つのタスクがあります。

Note

この演習に登場するすべてのサンク(取引とガード)は、次のような関数だと想定してかまいません。

  1. 引数として64ビットの非負整数を受け取り
  2. さらに64ビットの非負整数を返します。

1. 取引を覚えておく

窓口係は、一日の始めに新しい取引を教わり、顧客が来たときに後から適用できるように書き留めておきます。

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について:

  • 引数は64ビットの非負整数です。
  • 戻り値は64ビットの非負整数です。

2. 銀行の手引き

銀行の手引きには、よく使う取引の一覧が_ディスパッチテーブル_に保存されています。 各支店は一覧のコピーをそれぞれ保持しており、地域のポリシーに応じて異なる取引を登録できます。

呼び出し側が渡すディスパッチテーブルを操作する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について:

  • 第1引数は、ディスパッチテーブルのメモリーアドレスです。
  • 第2引数は64ビットの非負整数(インデックス)です。
  • 第3引数は取引です。
  • 戻り値はありません。

select_transactionについて:

  • 第1引数は、ディスパッチテーブルのメモリーアドレスです。
  • 第2引数は64ビットの非負整数(インデックス)です。
  • 第3引数は64ビットの非負整数(残高)です。
  • 戻り値は64ビットの非負整数です。

3. 月次明細を処理する

月末に、顧客の口座が照合されます。 その月に行われたすべての取引が、開始時の残高に次々と適用され、その結果が新しい残高になります。

関数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ビットの非負整数です。

4. ガード付きで処理する

銀行のポリシーでは、特定の取引を確定する前にチェックすることが求められています。 ガードとは、提案された残高を調べ、それが受け入れ可能かどうかを判断する関数です。 このガード関数は、承認する場合は非ゼロの値を返し、拒否する場合はゼロを返します。

process_with_guardを定義します。これは、開始時の残高、取引の配列のメモリーアドレス、配列内の取引の数、ガード関数を受け取ります。 それぞれの取引について、順番に次のようにします。

  1. 取引を現在の残高に適用して、暫定的な新しい残高を計算します。
  2. その暫定的な残高でガードを呼び出します。
  3. ガードが非ゼロを返したら、確定します。現在の残高が暫定的な残高になります。
  4. ガードがゼロを返したら、現在の残高は変わらず、その取引はスキップされます。

すべての取引を処理したあと、最終的な残高と、承認された取引の数を返します。

擬似コードでは、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

たとえば、次のように仮定します。

  1. add_interestは5を加算する取引で、service_feeは2を差し引く別の取引です
  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について:

  • 第1引数は64ビットの非負整数(開始時の残高)です。
  • 第2引数は、取引の配列のメモリーアドレスです。
  • 第3引数は64ビットの非負整数(配列の長さ)です。
  • 第4引数は、64ビットの非負整数を受け取って64ビットの非負整数を返すガード関数です。
  • 戻り値は2つの64ビットの非負整数です。最終的な残高はraxに、承認された取引の数はrdxに返されます。
GitHubで編集する リンクは新しいウィンドウまたはタブで開きます
x86-64 Assembly Exercism

簿記を始める準備はできましたか?

Exercismに登録すれば、22個のコンセプト130個の演習、そして本物の人間によるメンタリングとともに、x86-64 Assemblyを学んでマスターできます。すべて無料です。