Rutas
/
x86-64 Assembly
x86-64 Assembly
/
Ejercicios
/
La tarta de Piper
La tarta de Piper

La tarta de Piper

Ejercicio de aprendizaje

Introducción

Recursión

Una función es recursiva cuando se llama a sí misma.

Una diferencia clave entre una llamada a función y un bucle es que llamar a una función empuja a la pila la dirección a la que hay que volver. Esto significa que una función recursiva normalmente necesita más espacio de pila que un bucle equivalente.

Como consecuencia, una función que se llama a sí misma una y otra vez puede acabar agotando todo el espacio de pila. Esto se conoce como desbordamiento de pila.

Por eso toda función recursiva debe tener al menos un caso base, es decir, una situación en la que la función devuelve un valor sin llamarse a sí misma. Cualquier llamada recursiva debe acabar alcanzando un caso base.

Por ejemplo, la función factorial n! = n * (n - 1) * ... * 1 se puede definir de forma recursiva con 1 como caso base:

factorial:
    ; the argument `n` is passed on `rdi`
    ; the factorial will be returned on `rax`

    cmp rdi, 1
    jle .base_case     ; base case -> if rdi <= 1, return 1

    push rdi           ; save n
    dec rdi            ; rdi = n - 1
    call factorial     ; recursive call, rax = (n - 1)!
    pop rdi            ; restore n
    imul rax, rdi      ; rax = n * (n - 1)! = n!
    ret
.base_case:
    mov rax, 1
    ret

Fíjate en que factorial debe hacer push rdi antes de recursar y pop rdi después. Esto se debe a que todavía necesita n cuando la llamada recursiva devuelve el control, para poder calcular n * (n-1)!.

Fíjate también en que usar un registro que la función llamada deba conservar no resolvería este problema.

Aunque una función recursiva puede llegar a llamarse a sí misma, también es la función llamada por otra función. Eso significa que la función también debe conservar los registros que, como función llamada, debe guardar antes de usarlos, y restaurar su valor después de usarlos. Normalmente esto se hace con una secuencia de push/pop, como vimos en un concepto anterior.

Como cada marco de una función recursiva, salvo el del caso base, también es un llamador que necesita preservar sus propias variables locales, esta secuencia de push/pop debe repetirse en cada marco. Incluso guardando la variable directamente en la pila, sin usar registros, seguiría costando los mismos 8 bytes por marco.

Esto significa que cada llamada recursiva añade 8 bytes a la pila por la dirección de retorno que empuja call, más 8 bytes por cada variable local que necesite guardar. La función seguirá añadiendo esos bytes a la pila en cada marco hasta que alcance su caso base. Solo entonces empieza a desenrollarse en orden inverso, y cada llamada recursiva usa tantos pop como necesite y después un ret.

Por ejemplo, si se llamara a factorial con el argumento 10, se llamaría a sí misma nueve veces antes de alcanzar el caso base de 1. En ese momento se habrían usado 144 bytes para guardar la n (8 bytes) y la dirección de retorno (8 bytes) de cada marco anterior.

Llamada de cola

En algunas situaciones, una función no realiza más trabajo después de llamar a otra y antes de devolver el control.

Fíjate, por ejemplo, en este caso:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    call times_three
    ret

La función triple_of_square:

  • multiplica por sí mismo el argumento que recibe (en rdi), obteniendo su cuadrado;
  • después llama a times_three, que devuelve el triple del argumento que se le pasa.

Como resultado, triple_of_square devuelve 3*x², donde x es su argumento, que se pasa en rdi.

Fíjate en que en triple_of_square no se hace ningún trabajo después de llamar a times_three: la función simplemente devuelve el control. En una situación así, en lugar de usar call, una función puede usar jmp y transferir la ejecución a la función llamada:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    jmp times_three

Esto se conoce como llamada de cola.

La principal ventaja de una llamada de cola es que evita el coste adicional de call. Un call empuja una dirección de retorno a la pila y, para que el control vuelva a ese punto, debe haber un ret correspondiente.

Una llamada de cola se salta ambas cosas: no hay dirección de retorno que empujar ni un ret adicional con el que emparejarla, solo el ret de la propia función llamada.

Recursión de cola

Una llamada de cola es especialmente útil para funciones recursivas que pueden llamarse a sí mismas muchas veces antes de devolver el control.

Sin embargo, no toda llamada recursiva se puede convertir directamente en una llamada de cola. Como un jmp transfiere el control a la función llamada, quien llama no puede realizar más trabajo después de la llamada de cola.

Por ejemplo, la función factorial anterior no es recursiva de cola. Después de la llamada recursiva, todavía necesita multiplicar el resultado por la n actual, usando imul rax, rdi.

En situaciones así, a veces es posible usar un acumulador que vaya recogiendo los cálculos parciales y se devuelva al final. Por ejemplo, podemos definir un factorial_helper que haga la mayor parte del trabajo, y después factorial prepara un acumulador y le transfiere el control a factorial_helper:

factorial_helper:
    ; the argument `n` is passed on `rdi`
    ; `rax` is used as an accumulator and will be returned at the end

    cmp rdi, 1
    jle .base_case

    imul rax, rdi        ; we accumulate the partial result on `rax`
    dec rdi              ; rdi = n - 1
    jmp factorial_helper ; tail call to accumulate (n - 1)!
.base_case:
    ret                  ; returns the factorial already accumulated on `rax`

factorial:
    mov rax, 1           ; initial value for the accumulator
    jmp factorial_helper ; tail call

Como ya no se hace más trabajo después de la llamada recursiva, tampoco necesitamos guardar rdi. No hay ningún call ni ningún push rdi, así que cada iteración recursiva añade 0 bytes a la pila: no se usa espacio de pila adicional. Esta versión puede manejar valores de n arbitrariamente grandes sin desbordar la pila. Es a la vez más eficiente y más segura.

En algunos casos, manipulando el orden de las funciones, se puede evitar incluso el jmp a la función auxiliar. Por ejemplo, factorial y triple_of_square se pueden reescribir así:

factorial:
    mov rax, 1
factorial_helper:
    cmp rdi, 1
    jle .base_case

    imul rax, rdi
    dec rdi
    jmp factorial_helper
.base_case:
    ret

triple_of_square:
    imul rdi, rdi
times_three:
    imul rax, rdi, 3
    ret

En el fragmento anterior, la ejecución de factorial continúa directamente en factorial_helper. Lo mismo ocurre con triple_of_square y times_three. En ambos casos, la ejecución continúa de forma secuencial y parece que la función de cola no es más que una etiqueta local dentro de la función «principal».

En realidad, no hay ninguna diferencia esencial entre una etiqueta local cualquiera y una función. El ensamblador de x86-64 no da un trato especial a ninguno de los dos: no son más que direcciones en una sección con código ejecutable, como section .text.

De este modo, una función recursiva de cola se puede concebir esencialmente igual que un bucle en el que la llamada recursiva salta de vuelta al principio y el caso base es la condición que pone fin al bucle.

Instrucciones

Piper es una apasionada repostera de tartas.

Nadie sabe si se dedicó a hacer tartas por su nombre, o si cambió su nombre para que coincidiera con su afición. A primera vista, esto último no parece muy probable, pero verás, Piper está absolutamente fascinada por las tartas. Siempre está cacharreando en la cocina, retocando sus recetas y perfeccionando su oficio, para deleite absoluto de sus amigos. Nada escapa a su atención por el detalle: ni la temperatura de su horno, ni el peso de cada bola de masa, y desde luego tampoco la forma de la propia tarta.

¿Su último interés? Hornear tartas lo más circulares posible, hasta la perfección matemática, con la ayuda de su número favorito, lo has adivinado: π.

Piper encontró una fórmula maravillosa para calcular π de forma iterativa: la transformación de convergencia de Newton/Euler.

π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!

Ayuda a Piper a poner orden en su cocina y hornea su tarta matemáticamente perfecta.

1. Reparte la masa

Piper estiró esta mañana dos tandas de masa, con pesos diferentes (en g). Para que sus tartas sean uniformes, quiere repartir ambas tandas en bolas del mismo peso. Y, por supuesto, quiere que las porciones sean lo más grandes posible, ¡para desperdiciar la menor cantidad de masa posible!

El mayor peso que divide ambas tandas de forma exacta es su máximo común divisor. El algoritmo de Euclides lo calcula de forma recursiva:

  • gcd(a, 0) = a (caso base)
  • gcd(a, b) = gcd(b, a mod b)

Fíjate en que la llamada recursiva se encuentra en posición de cola: no ocurre nada después de ella. Define largest_portion de modo que el paso recursivo sea un jmp a la propia función, y no un call.

largest_portion(252, 105);
// => 21

Ambos argumentos son enteros de 64 bits no negativos. El valor devuelto es un entero de 64 bits no negativo.

2. Doble factorial

Ya sabes, gracias al concepto, cómo escribir el factorial normal usando recursión de cola. Esa misma función está en tu fichero de partida.

Sin embargo, la fórmula de Newton/Euler también usa dobles factoriales, escritos !!. El operador de doble factorial se define así:

0!! = 1
n!! = 1 * 3 * 5 * ... * n // if n is odd
n!! = 2 * 4 * 6 * ... * n // if n is even

Fíjate en que el doble factorial sigue el mismo patrón que el factorial, salvo que disminuye de 2 en 2 en cada paso en lugar de 1 en 1. Define la función double_factorial, que calculará el doble factorial mediante recursión de cola.

double_factorial(5);
// => 15
double_factorial(6);
// => 48

El argumento es un entero de 32 bits sin signo. El valor devuelto es un entero de 64 bits sin signo.

3. Transformación de convergencia de Newton/Euler

Ahora Piper tiene todas las herramientas que necesita. Define la función pipers_pi, que aproxima π usando un número determinado de términos de la fórmula de transformación de convergencia de Newton/Euler:

π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!

El numerador usa el factorial normal. ¡Puedes llamar a la función factorial que ya está definida para ti! El denominador usa el double_factorial que escribiste en la tarea 2.

Calculemos juntos el primer término. Para un límite superior de 0 (en lugar de infinito), obtenemos:

π / 2 ≈ sum for k from 0 to 0 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ (0!) / ( 2 * 0 + 1 )!!
π / 2 ≈ 0! / 1!!
π / 2 ≈ 1 / 1
π / 2 ≈ 1.0
π ≈ 2.0

Para un límite superior de 2, obtenemos en cambio:

π / 2 ≈ sum for k from 0 to 2 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ ((0!) / ( 2 * 0 + 1 )!!) + ((1!) / ( 2 * 1 + 1 )!!) + ((2!) / ( 2 * 2 + 1 )!!)
π / 2 ≈ 1 + (1! / 3!!) + (2! / 5!!)
π / 2 ≈ 1 + (1 / 3) + (2 / 15)
π / 2 ≈ 1.4666666
π ≈ 2.9333333

Cada término adicional mejorará la aproximación.

pipers_pi(0);
// => 2.0
pipers_pi(1);
// => 2.6666666
pipers_pi(2);
// => 2.9333333

El argumento es un entero de 32 bits no negativo. El valor devuelto es un número de coma flotante de 64 bits.

Editar en GitHub El enlace se abre en una ventana o pestaña nueva
x86-64 Assembly Exercism

¿Listo para empezar La tarta de Piper?

Regístrate en Exercism para aprender y dominar x86-64 Assembly con 22 conceptos130 ejercicios y mentoría humana real, todo gratis.