이전 개념에서 지역 레이블과 함수는 모두 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
값처럼 전달되는 함수 주소를 thunk라고 해요. thunk는 어셈블리에서 고차 프로그래밍, 즉 다른 코드를 다루는 코드를 구성하는 기본 요소예요.
함수 주소는 메모리에 저장했다가 나중에 다시 꺼낼 수도 있어요.
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
호출 사이에 지속되는 메모리를 읽거나 갱신하는 thunk는 그 앞에 무슨 일이 있었는지에 따라 다르게 동작할 수 있어요. 결과가 인자만으로 결정되지 않을 수도 있죠.
예를 들어, 함수를 받아 현재 카운트를 인자로 호출하고, 호출할 때마다 카운트를 늘리는 _카운터_가 있어요.
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가 호출되면, 저장해 둔 인자로 붙잡아 둔 함수를 실행해요.
상위 수준 언어에서 흔히 쓰이는 패턴, 예를 들어 콜백, 가상 메서드, 제너레이터, 커링, 함수 합성 등은 대부분 지속 상태와 짝을 이룬 thunk 위에 세워져 있어요.
여러분은 작은 마을 은행에서 회계 담당자로 일하고 있어요. 고객마다 계좌를 하나씩 가지고 있고, 그 잔액을 장부에 기록해요. 한 해 동안 이런 잔액에는 여러 거래가 적용돼요. 이자가 입금되고, 수수료가 차감되고, 보너스가 지급되고, 벌금이 부과돼요. 모든 거래는 잔액을 받아 새로운 잔액을 만들어요.
풀어야 할 과제가 네 개 있어요.
이 연습 문제의 모든 thunk(거래와 가드)는 다음 조건을 만족하는 함수라고 가정해도 돼요:
창구 직원은 하루가 시작될 때 새로운 거래를 배워서, 나중에 고객이 왔을 때 적용할 수 있도록 적어 둬요.
두 개의 함수를 정의해요:
remember_transaction은 거래를 받아 메모리에 저장해요.apply_remembered는 잔액을 받아 앞서 저장한 거래를 적용해요.예를 들어, add_interest가 이자 다섯 단위를 입금하는 거래라고 해봐요:
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의 경우:
은행 매뉴얼에는 자주 쓰이는 거래 목록이 _디스패치 테이블_에 저장되어 있어요. 지점마다 이 목록의 사본을 따로 관리하고, 지역 정책에 따라 서로 다른 거래를 등록할 수 있어요.
호출자가 넘겨주는 디스패치 테이블을 다루는 두 개의 함수를 정의해요:
register_transaction은 디스패치 테이블의 메모리 주소, 인덱스, 거래를 받아요.
그리고 그 거래를 테이블의 주어진 인덱스에 저장해요.select_transaction은 디스패치 테이블의 메모리 주소, 인덱스, 잔액을 받아요.
주어진 인덱스에 있는 거래를 찾아 잔액에 적용하고, 새로운 잔액을 반환해요.select_transaction은 찾아낸 거래에 단일 간접 꼬리 호출로 도달해야 해요.
예를 들어, manual이 빈 슬롯 네 개를 가진 디스패치 테이블의 메모리 주소라고 해봐요:
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
첫 번째 인자는 64비트 음이 아닌 정수예요. 두 번째 인자는 거래 배열의 메모리 주소예요. 세 번째 인자는 64비트 음이 아닌 정수(배열 길이)예요. 반환 값은 64비트 음이 아닌 정수예요.
은행 정책상 일부 거래는 확정되기 전에 검사를 거쳐야 해요. 가드는 제안된 잔액을 살펴보고 받아들일 만한지 판단하는 함수예요. 이 가드 함수는 승인하려면 0이 아닌 값을 반환하고, 거부하려면 0을 반환해요.
시작 잔액, 거래 배열의 메모리 주소, 배열에 있는 거래 개수, 가드 함수를 받는 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 이상일 때 0이 아닌 값을 반환하는 가드예요.그러면:
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에 가입하고 x86-64 Assembly 트랙을 개념 22개연습 문제 130개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.