¿Por qué las matrices en C decaen a punteros?

[Esta es una pregunta inspirada en una discusión reciente en otro lugar, y proporcionaré una respuesta correcta con ella.]

Me preguntaba sobre el extraño fenómeno C de matrices “en descomposición” a punteros, por ejemplo, cuando se usan como argumentos de funciones. Eso parece tan inseguro. También es inconveniente pasar la longitud explícitamente con él. Y puedo pasar el otro tipo de agregados – estructuras – perfectamente bien por valor; las estructuras no se descomponen

¿Cuál es la razón de esta decisión de diseño? ¿Cómo se integra con el lenguaje? ¿Por qué hay una diferencia en las estructuras?

Razón fundamental

Examinemos las llamadas a funciones porque los problemas son muy visibles allí: ¿Por qué las matrices no se pasan simplemente a las funciones como matrices, por valor, como una copia?

Primero hay una razón puramente pragmática: las matrices pueden ser grandes; puede que no sea aconsejable pasarlos por valor porque podrían exceder el tamaño de la stack, especialmente en la década de 1970. Los primeros comstackdores se escribieron en un PDP-7 con aproximadamente 9 kB de RAM.

También hay una razón más técnica enraizada en el lenguaje. Sería difícil generar código para una llamada a función con argumentos cuyo tamaño no se conoce en tiempo de comstackción. Para todas las matrices, incluidas las matrices de longitud variable en C moderna, simplemente las direcciones se colocan en la stack de llamadas. El tamaño de una dirección es, por supuesto, bien conocido. Incluso los lenguajes con tipos de matriz elaborados que llevan información de tiempo de ejecución no pasan los objetos propios de la stack. Estos lenguajes normalmente pasan “controles”, que es lo que C ha hecho efectivamente, también, durante 40 años. Ver Jon Skeet aquí y una explicación ilustrada que hace referencia (sic) aquí .

Ahora, un idioma puede hacer que sea un requisito que una matriz siempre tenga un tipo completo; es decir, siempre que se use, su statement completa, incluido el tamaño, debe estar visible. Esto es, después de todo, lo que C requiere de las estructuras (cuando se accede a ellas). En consecuencia, las estructuras se pueden pasar a funciones por valor. Exigir el tipo completo para las matrices también haría que las llamadas a funciones fueran fácilmente comstackbles y obviaría la necesidad de pasar argumentos de longitud adicionales: sizeof() seguiría funcionando como se esperaba dentro del destinatario. Pero imagina lo que eso significa. Si el tamaño fuera realmente parte del tipo de argumento de la matriz, necesitaríamos una función distinta para cada tamaño de matriz:

 // for user input. int average_Ten(int arr[10]); // for my new Hasselblad. int average_ThreeTrillionThreehundredninetythreeBillionNinehundredtwentyeightMillionEighthundredsixthousandfourhundred(int arr[16544*12400]); // ... 

De hecho, sería totalmente comparable a las estructuras de paso, que difieren en el tipo si sus elementos difieren (por ejemplo, una estructura con 10 elementos int y una con 16544 * 12400). Es obvio que las matrices necesitan más flexibilidad. Por ejemplo, como se demostró, uno no podría proporcionar con sensatez funciones de biblioteca generalmente utilizables que tomaran argumentos de matriz.

Este “enigma de tipeo fuerte” es, de hecho, lo que sucede en C ++ cuando una función toma una referencia a una matriz; esa es también la razón por la cual nadie lo hace, al menos no explícitamente. Es totalmente incómodo hasta el punto de ser inútil excepto en casos que apuntan a usos específicos, y en código genérico: las plantillas C ++ proporcionan flexibilidad en tiempo de comstackción que no está disponible en C.

Si, en una C existente, de hecho las matrices de tamaños conocidos deben pasar por valor, siempre existe la posibilidad de envolverlas en una estructura. Recuerdo que algunos encabezados relacionados con IP en Solaris definían estructuras familiares de direcciones con matrices en ellas, lo que les permitía copiarlas. Como el diseño de bytes de la estructura era fijo y conocido, tenía sentido.

Para algunos antecedentes, también es interesante leer El desarrollo del lenguaje en C, de Dennis Ritchie, sobre los orígenes del predecesor de C. C. BCPL no tenía matrices; la memoria era solo memoria lineal homogénea con punteros en ella.

La respuesta a esta pregunta se puede encontrar en el documento de Dennis Ritchie “El desarrollo del lenguaje C” (ver la sección “Embriónico C”).

Según Dennis Ritchie, las versiones nacientes de C heredaron / adoptaron directamente la semántica de matriz de los lenguajes B y BCPL, predecesores de C. En esos lenguajes, las matrices se implementaban literalmente como punteros físicos. Estos punteros apuntaban a bloques de memoria asignados independientemente que contenían los elementos de la matriz real. Estos punteros se inicializaron en tiempo de ejecución. Es decir, en los días B y BCPL las matrices se implementaron como objetos “binarios” (bipartitos): un puntero independiente apuntando a un bloque de datos independiente. No hubo diferencia entre la semántica de matriz y puntero en esos idiomas, aparte del hecho de que los punteros de matriz se inicializaron automáticamente. En cualquier momento, fue posible reasignar un puntero de matriz en B y BCPL para que apunte a otro lugar.

Inicialmente, este acercamiento a la semántica de matriz fue heredado por C. Sin embargo, sus inconvenientes se volvieron inmediatamente obvios cuando se introdujeron los tipos de struct en el lenguaje (algo que ni B ni BCPL tenían). Y la idea era que las estructuras deberían ser capaces de contener matrices de forma natural. Sin embargo, continuar con la naturaleza “bipartita” anterior de las matrices B / BCPL conduciría inmediatamente a una serie de complicaciones obvias con las estructuras. Por ejemplo, los objetos struct con matrices dentro requerirían una “construcción” no trivial en el punto de definición. Sería imposible copiar dichos objetos struct: una llamada de memcpy sin memcpy copiaría los punteros de la matriz sin copiar los datos reales. Uno no podría malloc struct objects, ya que malloc solo puede asignar memoria sin procesar y no activa ninguna inicialización no trivial. Y así sucesivamente y así sucesivamente.

Esto se consideró inaceptable, lo que condujo al rediseño de las matrices en C. En lugar de implementar matrices a través de punteros físicos, Ritchie decidió deshacerse de los punteros por completo. La nueva matriz se implementó como un solo bloque de memoria inmediato, que es exactamente lo que tenemos hoy en C. Sin embargo, por razones de compatibilidad con versiones anteriores, el comportamiento de las matrices B / BCPL se conservó (emuló) tanto como fue posible a nivel superficial: la nueva matriz C decayó fácilmente a un valor de puntero temporal, apuntando al comienzo de la matriz. El rest de la funcionalidad de la matriz se mantuvo sin cambios, confiando en ese resultado fácilmente disponible de la descomposición.

Para citar el documento antes mencionado

La solución constituyó el salto crucial en la cadena evolutiva entre el BCPL sin tipo y el tipo C. Eliminó la materialización del puntero en el almacenamiento y en su lugar causó la creación del puntero cuando el nombre del arreglo se menciona en una expresión. La regla, que sobrevive en el C de hoy, es que los valores del tipo de matriz se convierten, cuando aparecen en expresiones, en punteros al primero de los objetos que componen la matriz.

Esta invención permitió que la mayoría del código B existente siguiera funcionando, a pesar del cambio subyacente en la semántica del lenguaje. Los pocos progtwigs que asignaron nuevos valores a un nombre de matriz para ajustar su origen -posible en B y BCPL, sin sentido en C- se repararon fácilmente. Lo que es más importante, el nuevo lenguaje conservaba una explicación coherente y factible (aunque inusual) de la semántica de las matrices, mientras abría el camino hacia una estructura de tipos más completa.

Entonces, la respuesta directa a su pregunta “por qué” es la siguiente: las matrices en C se diseñaron para decaer a punteros con el fin de emular (lo más cerca posible) el comportamiento histórico de las matrices en los lenguajes B y BCPL.

Lleve su máquina del tiempo y vuelva a viajar a 1970. Comience a diseñar un lenguaje de progtwigción. Desea que se compile el siguiente código y haga lo esperado:

 size_t i; int* p = (int *) malloc (10 * sizeof (int)); for (i = 0; i < 10; ++i) p [i] = i; int a [10]; for (i = 0; i < 10; ++i) a [i] = i; 

Al mismo tiempo, quieres un lenguaje simple. Lo suficientemente simple como para poder comstackrlo en una computadora de 1970. La regla de que "a" se desintegra a "señalar al primer elemento de a" lo logra muy bien.