DEV Community

Baltasar García Perez-Schofield
Baltasar García Perez-Schofield

Posted on • Edited on

C, ¿el lenguaje de programación de los chamanes?

Cada vez más, los estudiantes de informática se forman con lenguajes de programación de verdadero alto nivel, como el típico Java, C#, Python... Todos ellos tienen algo en común: cuando te manejas con arrays, o matrices: te avisan cuando no aciertas a acceder dentro del array (por ejemplo, a la posición 90 en un vector de 10 posiciones).

Por ejemplo: el siguiente código lanza la excepción: IndexError, ya que obviamente no existe esa posición.

# Python

v = [11, 12, 13, 14 15]
print(v[90])
Enter fullscreen mode Exit fullscreen mode

Salida:

v[55]
~^^^^
IndexError: list index out of range
Enter fullscreen mode Exit fullscreen mode

Hay diferentes versiones para esto mismo en C# y Java. En C# se lanza la excepción IndexOutOfRangeException.

// C#

int[] v = [11, 12, 13, 14, 15];
System.Console.WriteLine( v[ 90 ] );
Enter fullscreen mode Exit fullscreen mode

Salida:

Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
Enter fullscreen mode Exit fullscreen mode

En Java, se lanza la excepción ArrayIndexOutOfBoundsException.

// Java

public class Main {
    public static void main(String[] args)
    {
        int v[] = new int[]{ 11, 12, 13, 14, 15 };
        System.out.println( v[ 90 ] );
    }
}
Enter fullscreen mode Exit fullscreen mode

Salida

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 90 out of bounds for length 5
Enter fullscreen mode Exit fullscreen mode

Por cierto, un pequeño detalle que me gustaría explorar en otro post: ¿por qué es necesario renombrar todos los conceptos cuando se crea un lenguaje de programación nuevo? Python y Java fueron diseñados más o menos al mismo tiempo, así que tienen su excusa, pero... ¿por qué Java y C# tienen nombres distintos para las excepciones, o para las clases y métodos de acceso a la consola? Más aún cuando muchos programas en Java pueden compilarse sin modificaciones en C#.

Volviendo al tema entre manos, está claro que, especialmente con la forma de enseñar programación en la gran mayoría de las facultades de informática, todos los estudiantes asumen que este es el comportamiento "normal". Dicho de otra forma, todos los lenguajes de programación "normales" se comportan de esta manera. No es "culpa" de nadie, así es como son las cosas, punto.

La pregunta surgió en clases cuando llegamos al punto de los índices negativos en Python. ¡Sí! Python permite la siguiente transformación:

v = [11, 12, 13, 14, 15]

for i in range(len(v)):
    print(f"#{i=}: {v[i]=} | #{(i + 1)=}: #{(len(v) - (i + 1))=}: {v[(len(v) - (i + 1))]=} | #{-(i + 1)=}: {v[-(i + 1)]=}")
Enter fullscreen mode Exit fullscreen mode

Salida:

#i=0: v[i]=11 | #(i + 1)=1: #(len(v) - (i + 1))=4: v[(len(v) - (i + 1))]=15 | #-(i + 1)=-1: v[-(i + 1)]=15
#i=1: v[i]=12 | #(i + 1)=2: #(len(v) - (i + 1))=3: v[(len(v) - (i + 1))]=14 | #-(i + 1)=-2: v[-(i + 1)]=14
#i=2: v[i]=13 | #(i + 1)=3: #(len(v) - (i + 1))=2: v[(len(v) - (i + 1))]=13 | #-(i + 1)=-3: v[-(i + 1)]=13
#i=3: v[i]=14 | #(i + 1)=4: #(len(v) - (i + 1))=1: v[(len(v) - (i + 1))]=12 | #-(i + 1)=-4: v[-(i + 1)]=12
#i=4: v[i]=15 | #(i + 1)=5: #(len(v) - (i + 1))=0: v[(len(v) - (i + 1))]=11 | #-(i + 1)=-5: v[-(i + 1)]=11
Enter fullscreen mode Exit fullscreen mode

Efectivamente, v[len(v) - 1], que obtiene la última posición, es equivalente a v[-1]. En realidad, no es que realmente Python acepte índices negativos, sino que es una más de las posibilidades expresivas de Python: los índices negativos se transforman, de manera que cuentan desde la longitud total del vector. Una forma alternativa de expresar esto mismo es la de C#, que permite v[^1].

¡Pero ningún lenguaje de programación "normal" permite índice negativos! ¿No?
Bueno, con C sí puedes hacerlo.
Y así es como desciendes por la madriguera del conejo hacia el mundo de las maravillas.

El siguiente programa es válido en C, y también en C++:

#include <stdio.h>


int main(int argc, char **argv)
{
    int v[] = {11, 12, 13, 14, 15};

    for(int i = 0; i < (sizeof( v ) / sizeof( int )); ++i) {
        printf( " #%d: %d |", i, i[ v ] );
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Salida:

 0: 11 | 1: 12 | 2: 13 | 3: 14 | 4: 15 |
Enter fullscreen mode Exit fullscreen mode

La razón de que esto funcione es simple: en realidad, v[ i ] (o i[ v ]), no son más que azúcar sintáctico para *(v + i) o *(i + v). Ya más tarde, cuando el compilador genera el código, esto se transforma en: *(v + (sizeof(int) * i)), ya que los incrementos o decrementos reales siempre dependen del tamaño del tipo del puntero. Claro, es indiferente generar *(v + i) desde v[ i ]. o *(i + v) desde i[v].

¿Qué tiene que ver esta peculiaridad de C con los índices negativos? Bueno, se trata de que nos hagamos a la idea de que el lenguaje de programación C es muy, muy antiguo y relativamente sencillo (de hecho, ya no se considera de forma pura un lenguaje de alto nivel). Hay que tener en cuenta que C es un lenguaje de programación de sistemas, por lo que es normal que su nivel de expresividad esté muy cercana al hardware.

Así que... ¿se pueden indicar valores negativos en C? Por supuesto. Veámoslo:

#include <stdio.h>


int main(int argc, char **argv)
{
    int v[] = {11, 12, 13, 14, 15};
    int len = sizeof( v ) / sizeof( int );

    for(int i = -len; i < len; ++i) {
        printf( " #%d: %d |", i, i[ v ] );
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Salida:

#-5: 1 | #-4: 0 | #-3: 0 | #-2: -2 | #-1: 5 | #0: 11 | #1: 12 | #2: 13 | #3: 14 | #4: 15 |
Enter fullscreen mode Exit fullscreen mode

En el caso de este programa, estamos recorriendo el espacio en memoria anterior a la posición de v. En este caso, funciona, y nos devuelve el contenido de la memoria en esas posiciones. Por supuesto, esto entra dentro de lo que se conoce como comportamiento indefinido (undefined behaviour), ya que está recorriendo memoria que no se ha reservado previamente. De nuevo, en este caso, estamos explorando las posiciones previas a v dentro de la pila de llamadas o stack. Recordemos que, para cada función, se crea un marco dentro del stack en el que se almacena la dirección de retorno y las variables locales.

Podemos, de hecho, ver el contenido de v1 desde v2.

#include <stdio.h>


int main(int argc, char **argv)
{
    int v1[] = {11, 12, 13, 14, 15};
    int v2[] = {21, 22, 23, 24, 25};    
    int len = sizeof( v1 ) / sizeof( int );

    for(int i = -(2 * len); i < len; ++i) {
        printf( " #%d: %d |", i, i[ v2 ] );
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Salida:

#-10: -10 | #-9: 5 | #-8: 11 | #-7: 12 | #-6: 13 | #-5: 14 | #-4: 15 | #-3: 0 | #-2: 0 | #-1: 0 | #0: 21 | #1: 22 | #2: 23 | #3: 24 | #4: 25 |
Enter fullscreen mode Exit fullscreen mode

Efectivamente, dado que tanto v1 como v2 están creados dentro del stack, y teniendo en cuenta que uno se crea antes que el otro, es probable que suceda que si recorremos las posiciones anteriores a v2 (interpretadas como int), aparezcan los valores de v1.

Ni qué decir tiene que esto vuelve a ser comportamiento indefinido. No tiene sentido que se asuma que, por el hecho de que hayan sido definidos unos antes que otros, la posición de un vector en memoria puede adivinarse desde otro.

Un puntero en acción

Estas prácticas son conocidas como aritmética de punteros. Esta característica tiene mucha utilidad... bien utilizada, claro.

Sí, hay usos legítimos de la aritmética de punteros. Por ejemplo, teniendo en cuenta que C almacena las matrices por filas, podríamos utilizar la aritmética de punteros para recorrer una matriz por columnas.

#include <stdio.h>


#define LEN 3


int main(int argc, char **argv)
{
    const int m0[][LEN] = {{11, 12, 13}, {21, 22, 23}, {31, 32, 33}};    
    const int * m = &m0[0][0];

    // Recorrido por columnas
    for(int i = 0; i < LEN; ++i) {
        for(int j = 0; j < LEN; ++j) {
            printf( " #%d, %d |",
                    ( j * LEN ) + i,
                    m[ ( j * LEN ) + i ] );
        }
    }

    // Recorrido secuencial
    printf("\n");
    for(int n = 0; n < LEN * LEN; ++n) {
        printf( " %d ", m[ n ] );
    }

    return 0;
}

Enter fullscreen mode Exit fullscreen mode

Salida:

#0, 11 | #3, 21 | #6, 31 | #1, 12 | #4, 22 | #7, 32 | #2, 13 | #5, 23 | #8, 33 |
 11  12  13  21  22  23  31  32  33
Enter fullscreen mode Exit fullscreen mode

En la salida, podemos ver el recorrido por columnas en la primera línea, y el recorrido por filas, lineal, en la siguiente; a pesar de que la matriz está creada como un vector de vectores, o lo que es lo mismo, un vector de punteros a vectores (por cada una de las filas). Pero esto no es más que un artificio creado por C, como se puede ver en el siguiente esquema:

[ 0 ]   --->   [11,
                12,
                13,
[ 1 ]   --->    21,
                22,
                23,
[ 2 ]   --->    31,
                32,
               [33]
Enter fullscreen mode Exit fullscreen mode

En conclusión, C es un lenguaje de programación muy antiguo y relativamente sencillo, que crea muy pequeñas abstracciones por encima del hardware. Esto proporciona a C una gran potencia, pero también provoca grandes errores potenciales de seguridad, casi siempre basados en escribir más allá del final de un vector, algo que puede aprovecharse para colocar código directamente para ser ejecutado tras el retorno de la función.

Por otra parte, debido precisamente a la presencia potencial de errores de seguridad, C es hoy por hoy un lenguaje de programación que las nuevas generaciones de programadores desconoce, pues la enseñanza ha pasado a enfocarse en lenguajes de programación de mucho más alto nivel.

Y es por eso que, hoy por hoy, para las nuevas generaciones de informáticos C, es poco más o menos que magia negra.

Top comments (2)

Collapse
 
franciscoortin profile image
Francisco Ortin

Es una lástima que C esté perdiendo peso en los planes de estudio. Es el puente perfecto para entender la arquitectura de un ordenador: ofrece la profundidad necesaria para comprender cómo funciona el hardware sin la complejidad del ensamblador.

Collapse
 
baltasarq profile image
Baltasar García Perez-Schofield

¡Cierto! Y eso que aquí, empezamos con C++, pero claro, tomando los aspectos de más alto nivel. Cogemos string, por ejemplo, y no vectores de char.