Mecánica del lenguaje de pilas y punteros.

Preludio


Este es el primero de cuatro artículos de la serie que proporcionará información sobre la mecánica y el diseño de punteros, pilas, montones, análisis de escape y semántica de Go / puntero. Esta publicación trata sobre pilas y punteros.

Tabla de contenido:

  1. Mecánica del lenguaje en pilas y punteros
  2. Mecánica del lenguaje en el análisis de escape ( traducción )
  3. Mecánica del lenguaje en perfiles de memoria
  4. Filosofía de diseño sobre datos y semántica

Introducción


No voy a disimular, los punteros son difíciles de entender. Si se usa incorrectamente, los punteros pueden causar errores desagradables e incluso problemas de rendimiento. Esto es especialmente cierto cuando se escriben programas competitivos o multiproceso. No es sorprendente que muchos idiomas intenten ocultar los punteros de los programadores. Sin embargo, si escribe en Go, no puede escapar de los punteros. Sin una comprensión clara de los punteros, será difícil para usted escribir código limpio, simple y eficiente.

Bordes del marco


Las funciones se realizan dentro de los límites de los marcos que proporcionan un espacio de memoria separado para cada función correspondiente. Cada marco permite que la función funcione en su propio contexto, y también proporciona control de flujo. Una función tiene acceso directo a la memoria dentro de su marco a través de un puntero, pero el acceso a la memoria fuera del marco requiere acceso indirecto. Para que una función acceda a la memoria fuera de su marco, esta memoria debe usarse junto con esta función. La mecánica y las limitaciones establecidas por estos límites deben entenderse y estudiarse primero.

Cuando se llama a una función, se produce una transición entre dos cuadros. El código va del marco de la función de llamada al marco de la función llamada. Si se necesitan los datos para llamar a la función, estos datos deben transferirse de una trama a otra. La transferencia de datos entre dos cuadros en Go se realiza "por valor".

La ventaja de la transmisión de datos "por valor" es la legibilidad. El valor que ve en la llamada de función es lo que se copia y acepta en el otro lado. Es por eso que asocio "pasar por valor" con WYSIWYG, porque lo que ves es lo que obtienes. Todo esto le permite escribir código que no oculta el costo de cambiar entre dos funciones. Esto ayuda a mantener un buen modelo mental de cómo cada llamada de función afectará el programa durante la transición.

Mire este pequeño programa que llama a una función pasando datos enteros "por valor":

Listado 1:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11    // Pass the "value of" the count.
12    increment(count)
13
14    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20    // Increment the "value of" inc.
21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }

Cuando se inicia el programa Go, el tiempo de ejecución crea la rutina principal para comenzar a ejecutar todo el código, incluido el código dentro de la función principal. Gorutin es la ruta de ejecución que encaja en el hilo del sistema operativo, que finalmente se ejecuta en algún núcleo. A partir de la versión 1.8, cada rutina se proporciona con un bloque inicial de memoria continua de 2048 bytes de tamaño, que forma el espacio de la pila. Este tamaño de pila inicial ha cambiado con los años y puede cambiar en el futuro.

La pila es importante porque proporciona espacio de memoria física para los límites de trama que se asignan a cada función individual. Para cuando la rutina principal realice la función principal en el Listado 1, la pila de programas (en un nivel muy alto) se verá así:

Figura 1:



En la Figura 1, puede ver que parte de la pila estaba "enmarcada" para la función principal. Esta sección se llama " marco de pila ", y es este marco el que denota el límite de la función principal en la pila. El marco se establece como parte del código que se ejecuta cuando se llama a la función. También puede ver que la memoria para la variable de recuento se asignó a 0x10429fa4 dentro del marco para main.

Hay otro punto interesante, ilustrado en la Figura 1. Toda la memoria de la pila bajo el marco activo no es válida, pero la memoria del marco activo y superior es válida. Debe comprender claramente el límite entre la parte válida e inválida de la pila.

Direcciones


Las variables se utilizan para asignar un nombre a una celda de memoria específica para mejorar la legibilidad del código y ayudarlo a comprender con qué datos está trabajando. Si tiene una variable, entonces tiene un valor en la memoria, y si tiene un valor en la memoria, entonces debe tener una dirección. En la línea 09, la función principal llama a la función println incorporada para mostrar el "valor" y la "dirección" de la variable de conteo.

Listado 2:

09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

Usar el signo "&" para obtener la dirección de la ubicación de una variable no es nuevo, otros idiomas también usan este operador. La salida de la línea 09 debería verse como la salida a continuación si está ejecutando código en una arquitectura de 32 bits como Go Playground:

Listado 3:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

Llamada de función


Luego, en la línea 12, la función principal llama a la función de incremento.

Listado 4:

12    increment(count)

Hacer una llamada de función significa que el programa debe crear una nueva sección de memoria en la pila. Sin embargo, todo es un poco más complicado. Para completar con éxito una llamada de función, se espera que los datos se transfieran a través del límite del marco y se coloquen en un nuevo marco durante la transición. En particular, se espera copiar y transmitir un valor entero durante la llamada. Puede ver este requisito mirando la declaración de la función de incremento en la línea 18.

Listado 5:

18 func increment(inc int) {

Si vuelve a mirar la llamada a la función de incremento en la línea 12, verá que el código pasa el "valor" de la cuenta variable. Este valor se copiará, transferirá y colocará en un nuevo marco para la función de incremento. Recuerde que la función de incremento solo puede leer y escribir en la memoria en su propio marco, por lo que necesita la variable inc para obtener, almacenar y acceder a su propia copia del valor del contador transmitido.

Justo antes de que el código dentro de la función de incremento comience a ejecutarse, la pila de programas (a un nivel muy alto) se verá así:

Figura 2:



Puede ver que ahora hay dos cuadros en la pila: uno para el principal y otro para el incremento. Dentro del marco para el incremento, puede ver la variable inc que contiene el valor 10, que se copió y pasó durante la llamada a la función. La dirección variable inc es 0x10429f98, y está menos en la memoria porque los marcos se insertan en la pila, que son solo detalles de implementación que no significan nada. Lo importante es que el programa recuperó el valor de conteo del marco para main y colocó una copia de este valor en el marco para aumentar usando la variable inc.

El resto del código dentro del incremento incrementa y muestra el "valor" y la "dirección" de la variable inc.

Listado 6:

21    inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

La salida de la línea 22 en el patio de recreo debería verse así:

Listado 7:

inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

Así es como se ve la pila después de ejecutar las mismas líneas de código:

Figura 3:



Después de ejecutar las líneas 21 y 22, la función de incremento finaliza y devuelve el control a la función principal. Luego, la función principal muestra nuevamente el "valor" y la "dirección" del recuento de variables locales en la línea 14.

Listado 8:

14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

La salida completa del programa en el patio de recreo debería verse así:

Listado 9:

count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

El valor de recuento en el marco para main es el mismo antes y después de la llamada al incremento.

Regreso de funciones


¿Qué le sucede realmente a la memoria en la pila cuando la función sale y el control vuelve a la función de llamada? La respuesta corta es nada. Así es como se ve la pila después de que regresa la función de incremento:

Figura 4:



La pila se ve exactamente igual que en la Figura 3, excepto que el marco asociado con la función de incremento ahora se considera memoria no válida. Esto se debe a que el marco para main ahora está activo. La memoria creada para la función de incremento ha permanecido intacta.

Borrar el marco de memoria de la función de retorno será una pérdida de tiempo, porque no se sabe si esta memoria volverá a ser necesaria alguna vez. Entonces el recuerdo permaneció como estaba. Durante cada llamada de función, cuando se toma un marco, se borra la memoria de la pila para este marco. Esto se realiza inicializando cualquier valor que se ajuste al marco. Como todos los valores se inicializan como su "valor cero", las pilas se borran correctamente con cada llamada a la función.

Compartir valor


¿Qué pasaría si fuera importante para la función de incremento trabajar directamente con la variable de conteo que existe dentro del marco para main? Aquí es donde llega el momento de los punteros. Los punteros tienen un propósito: compartir un valor con una función para que la función pueda leer y escribir este valor, incluso si el valor no existe directamente dentro de su marco.

Si no cree que necesita "compartir" el valor, entonces no necesita usar un puntero. Al aprender punteros, es importante pensar que usar un diccionario limpio, no operadores o sintaxis. Recuerde que los punteros están destinados a ser compartidos y cuando lea el código, reemplace el operador & con la frase "compartir".

Tipos de punteros


Para cada tipo que declaró, o que fue declarado directamente por el propio lenguaje, obtendrá un tipo de puntero gratuito que puede usar para compartir. Ya hay un tipo incorporado llamado int, por lo que hay un tipo de puntero llamado * int. Si declara un tipo llamado Usuario, obtendrá un tipo de puntero llamado * Usuario de forma gratuita.

Todos los tipos de punteros tienen dos características idénticas. Primero, comienzan con el carácter *. En segundo lugar, todos tienen el mismo tamaño en memoria y una representación que ocupa 4 u 8 bytes que representan la dirección. En arquitecturas de 32 bits (por ejemplo, en el patio de recreo), los punteros requieren 4 bytes de memoria, y en arquitecturas de 64 bits (por ejemplo, su computadora) requieren 8 bytes de memoria.

En la especificación, tipos de punterose consideran literales de tipo , lo que significa que son tipos sin nombre formados por un tipo existente.

Acceso indirecto a memoria


Mire este pequeño programa que realiza una llamada a la función, pasando la dirección "por valor". Esto dividirá la variable de conteo del marco de la pila de main con la función de incremento:

Listado 10:

01 package main
02
03 func main() {
04
05    // Declare variable of type int with a value of 10.
06    count := 10
07
08    // Display the "value of" and "address of" count.
09    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11    // Pass the "address of" count.
12    increment(&count)
13
14    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20    // Increment the "value of" count that the "pointer points to". (dereferencing)
21    *inc++
22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }

Se hicieron tres cambios interesantes al programa original. El primer cambio está en la línea 12:

Listado 11:

12    increment(&count)

Esta vez, en la línea 12, el código no copia y pasa el "valor" a la variable de conteo, sino que pasa su "dirección" en lugar de la variable de conteo. Ahora puede decir: "Estoy compartiendo" el recuento de variables con el incremento de la función. Esto es lo que dice el operador &: "compartir".

Comprenda que esto sigue siendo "pasar por valor", y la única diferencia es que el valor que pasa es la dirección, no el número entero. Las direcciones también son valores; Esto es lo que se copia y se pasa a través del borde del marco para llamar a la función.

Dado que el valor de la dirección se copia y se pasa, necesita una variable dentro del marco de incremento para obtener y guardar esta dirección entera. Una declaración de variable de puntero entero está en la línea 18.

Listado 12:

18 func increment(inc *int) {

Si pasó la dirección del valor de tipo Usuario, la variable debería declararse como * Usuario. A pesar de que todas las variables de puntero almacenan valores de dirección, no se les puede pasar ninguna dirección, solo direcciones asociadas con el tipo de puntero. El principio básico de compartir un valor es que la función receptora debe leer o escribir en ese valor. Necesita información sobre el tipo de cualquier valor para leer y escribir en él. El compilador se asegurará de que solo los valores asociados con el tipo de puntero correcto se utilicen con esta función.

Así es como se ve la pila después de llamar a la función de incremento:

Figura 5:



La Figura 5 muestra el aspecto de la pila cuando se realiza "pasar por valor" utilizando la dirección como valor. La variable de puntero dentro del marco para la función de incremento ahora apunta a la variable de conteo, que se encuentra dentro del marco para main.

Ahora, usando la variable de puntero, la función puede realizar una operación indirecta de lectura y cambio para la variable de conteo ubicada dentro del marco para main.

Listado 13:

21    *inc++

Esta vez, el carácter * actúa como operador y se aplica a la variable de puntero. Usar * como operador significa "el valor al que apunta el puntero". Una variable de puntero proporciona acceso indirecto a la memoria fuera del marco de la función que la utiliza. A veces, esta lectura o escritura indirecta se llama desreferencia de puntero. La función de incremento aún necesita tener una variable de puntero en su marco, que puede leer directamente para realizar un acceso indirecto.

La Figura 6 muestra cómo se ve la pila después de la línea 21.

Figura 6:



Aquí está el resultado final de este programa:

Listado 14:

count:  Value Of[ 10 ]              Addr Of[ 0x10429fa4 ]
inc:    Value Of[ 0x10429fa4 ]      Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
count:  Value Of[ 11 ]              Addr Of[ 0x10429fa4 ]

Puede observar que el "valor" de la variable de puntero inc coincide con la "dirección" de la variable de conteo. Esto establece una relación de intercambio que permite el acceso indirecto a la memoria fuera del marco. Tan pronto como la función de incremento escribe a través del puntero, el cambio es visible para la función principal cuando se le devuelve el control.

Las variables de puntero no son especiales


Las variables de puntero no son especiales porque son las mismas variables que cualquier otra variable. Tienen una asignación de memoria y contienen significado. Dio la casualidad de que todas las variables de puntero, independientemente del tipo de valor al que puedan apuntar, siempre tienen el mismo tamaño y presentación. Lo que puede ser confuso es que el carácter * actúa como un operador dentro del código y se usa para declarar un tipo de puntero. Si puede distinguir una declaración de tipo de una operación de puntero, esto puede ayudar a eliminar cierta confusión.

Conclusión


Esta publicación describe el propósito de los punteros, el funcionamiento de la pila y la mecánica de los punteros en Go. Este es el primer paso para comprender la mecánica, los principios de diseño y las técnicas de uso necesarias para escribir código coherente y legible.

Al final, esto es lo que aprendiste:

  • Las funciones se realizan dentro de los límites del marco, que proporcionan un espacio de memoria separado para cada función correspondiente.
  • Cuando se llama a una función, se produce una transición entre dos cuadros.
  • La ventaja de la transmisión de datos "por valor" es la legibilidad.
  • La pila es importante porque proporciona espacio de memoria física para los límites de trama que se asignan a cada función individual.
  • Toda la memoria de la pila debajo del marco activo no es válida, pero la memoria del marco activo y superior es válida.
  • , .
  • , , .
  • — , , .
  • , , , , .
  • - , .
  • - - , , . , .

All Articles