3.8.5. Arrays

Los arrays son un tipo compuesto porque permiten agrupar muchas variables, una a continuación de la otra, bajo un identificador único. Si dice:

int a[10];

Se crea espacio para 10 variables int colocadas una después de la otra, pero sin identificadores únicos para cada variable. En su lugar, todas están englobadas por el nombre a.

Para acceder a cualquiera de los elementos del array, se utiliza la misma sintaxis de corchetes que se utiliza para definir el array:

a[5] = 47;

Sin embargo, debe recordar que aunque el tamaño de a es 10, se seleccionan los elementos del array comenzando por cero (esto se llama a veces indexado a cero[45]), de modo que sólo se pueden seleccionar los elementos del array de 0 a 9, como sigue:

//: C03:Arrays.cpp
#include <iostream>
using namespace std;

int main() {
  int a[10];
  for(int i = 0; i < 10; i++) {
    a[i] = i * 10;
    cout << "a[" << i << "] = " << a[i] << endl;
  }
} ///:~

Listado 3.50. C03/Arrays.cpp


Los accesos a arrays son extremadamente rápidos. Sin embargo, si se indexa más allá del final del array, no hay ninguna red de seguridad: se pisarán otras variables. La otra desventaja es que se debe definir el tamaño del array en tiempo de compilación; si se quiere cambiar el tamaño en tiempo de ejecución no se puede hacer con la sintaxis anterior (C tiene una manera de crear un array dinámicamente, pero es significativamente más sucia). El vector de C++, presentado en el capítulo anterior, proporciona un objeto parecido a un array que se redimensiona automáticamente, de modo que suele ser una solución mucho mejor si el tamaño del array no se puede conocer en tiempo de compilación.

Se puede hacer un array de cualquier tipo, incluso de structs:

//: C03:StructArray.cpp
// An array of struct

typedef struct {
  int i, j, k;
} ThreeDpoint;

int main() {
  ThreeDpoint p[10];
  for(int i = 0; i < 10; i++) {
    p[i].i = i + 1;
    p[i].j = i + 2;
    p[i].k = i + 3;
  }
} ///:~

Listado 3.51. C03/StructArray.cpp


Fíjese cómo el identificador i del struct es independiente del i del bucle for.

Para comprobar que cada elemento del array es contiguo con el siguiente, puede imprimir las direcciones de la siguiente manera:

//: C03:ArrayAddresses.cpp
#include <iostream>
using namespace std;

int main() {
  int a[10];
  cout << "sizeof(int) = "<< sizeof(int) << endl;
  for(int i = 0; i < 10; i++)
    cout << "&a[" << i << "] = " 
         << (long)&a[i] << endl;
} ///:~

Listado 3.52. C03/ArrayAddresses.cpp


Cuando se ejecuta este programa, se ve que cada elemento está separado por el tamaño de un int del anterior. Es decir, están colocados uno a continuación del otro.

Punteros y arrays

El identificador de un array es diferente de los identificadores de las variables comunes. Un identificador de un array no es un valor izquierdo; no se le puede asignar nada. En realidad, no es más que un punto de enganche con la sintaxis de corchetes, y cuando se usa el nombre de un array, sin los corchetes, lo que se obtiene es la dirección inicial del array:

//: C03:ArrayIdentifier.cpp
#include <iostream>
using namespace std;

int main() {
  int a[10];
  cout << "a = " << a << endl;
  cout << "&a[0] =" << &a[0] << endl;
} ///:~

Listado 3.53. C03/ArrayIdentifier.cpp


Cuando se ejecuta este programa, se ve que las dos direcciones (que se imprimen en hexadecimal, ya que no se moldea a long) son la misma.

De modo que una manera de ver el identificador de un array es como un puntero de sólo lectura al principio de éste. Y aunque no se pueda hacer que el identificador del array apunte a cualquier otro sitio, se puede crear otro puntero y utilizarlo para moverse dentro del array. De hecho, la sintaxis de corchetes también funciona con punteros convencionales:

//: C03:PointersAndBrackets.cpp
int main() {
  int a[10];
  int* ip = a;
  for(int i = 0; i < 10; i++)
    ip[i] = i * 10;
} ///:~

Listado 3.54. C03/PointersAndBrackets.cpp


El hecho de que el nombre de un array produzca su dirección de inicio resulta bastante importante cuando hay que pasar un array a una función. Si declara un array como un argumento de una función, lo que realmente está declarando es un puntero. De modo que en el siguiente ejemplo, func1() y func2() tienen en la práctica la misma lista de argumentos:

//: C03:ArrayArguments.cpp
#include <iostream>
#include <string>
using namespace std;

void func1(int a[], int size) {
  for(int i = 0; i < size; i++)
    a[i] = i * i - i;
}

void func2(int* a, int size) {
  for(int i = 0; i < size; i++)
    a[i] = i * i + i;
}

void print(int a[], string name, int size) {
  for(int i = 0; i < size; i++)
    cout << name << "[" << i << "] = " 
         << a[i] << endl;
}

int main() {
  int a[5], b[5];
  // Probably garbage values:
  print(a, "a", 5);
  print(b, "b", 5);
  // Initialize the arrays:
  func1(a, 5);
  func1(b, 5);
  print(a, "a", 5);
  print(b, "b", 5);
  // Notice the arrays are always modified:
  func2(a, 5);
  func2(b, 5);
  print(a, "a", 5);
  print(b, "b", 5);
} ///:~

Listado 3.55. C03/ArrayArguments.cpp


A pesar de que func1() y func2() declaran sus argumentos de distinta forma, el uso es el mismo dentro de la función. Hay otros hechos que revela este ejemplo: los arrays no se pueden pasar por valor[46], es decir, que nunca se puede obtener automáticamente una copia local del array que se pasa a una función. Por eso, cuando se modifica un array, siempre se está modificando el objeto externo. Eso puede resultar un poco confuso al principio, si lo que se espera es el paso-por-valor como en los argumentos ordinarios.

Fíjese en que print() utiliza la sintaxis de corchetes para los argumentos de tipo array. Aunque la sintaxis de puntero y la sintaxis de corchetes son en la práctica lo mismo cuando se están pasando arrays como argumentos, la sintaxis de corchetes deja más claro al lector que se pretende que dicho argumento sea un array.

Observe también que el argumento size se pasa en cada caso. La dirección no es suficiente información al pasar un array; siempre debe ser posible conocer el tamaño del array dentro de la función, de manera que no se salga de los límites de dicho array.

Los arrays pueden ser de cualquier tipo, incluyendo arrays de punteros. De hecho, cuando se quieren pasar argumentos de línea de comandos al programa, C y C++ tienen una lista de argumentos especial para main(), que tiene el siguiente aspecto:

int main(int argc, char* argv[]) { // ...

El primer argumento es el número de elementos en el array, que es el segundo argumento. El segundo argumento es siempre un array de char*, porque los argumentos se pasan desde la línea de comandos como arrays de caracteres (y recuerde, un array sólo se puede pasar como un puntero). Cada bloque de caracteres delimitado por un espacio en blanco en la línea de comandos se aloja en un elemento separado en el array. El siguiente programa imprime todos los argumentos de línea de comandos recorriendo el array:

//: C03:CommandLineArgs.cpp
#include <iostream>
using namespace std;

int main(int argc, char* argv[]) {
  cout << "argc = " << argc << endl;
  for(int i = 0; i < argc; i++)
    cout << "argv[" << i << "] = " 
         << argv[i] << endl;
} ///:~

Listado 3.56. C03/CommandLineArgs.cpp


Observe que argv[0] es la ruta y el nombre del programa en sí mismo. Eso permite al programa descubrir información de sí mismo. También añade un argumento más al array de argumentos del programa, de modo que un error común al recoger argumentos de línea de comandos es tomar argv[0] cuando se quiere argv[1].

No es obligatorio utilizar argc y argv como identificadores de los parámetros de main(); estos identificadores son sólo convenciones (pero puede confundir al lector si no se respetan). Además, hay un modo alternativo de declarar argv:

int main(int argc, char** argv) { // ...

Las dos formas son equivalentes, pero a mi juicio la versión utilizada en este libro es la más intuitiva al leer el código, ya que dice, directamente, «Esto es un array de punteros a carácter».

Todo lo que se obtiene de la línea de comandos son arrays de caracteres; si quiere tratar un argumento como algún otro tipo, es responsabilidad suya convertirlo dentro del programa. Para facilitar la conversión a números, hay algunas funciones de ayuda en la librería de C Estándar, declaradas en <cstdlib>. Las más fáciles de utilizar son atoi(), atol(), y atof() para convertir un array de caracteres ASCII a int, long y double, respectivamente. A continuación, un ejemplo utilizando atoi() (las otras dos funciones se invocan del mismo modo):

//: C03:ArgsToInts.cpp
// Converting command-line arguments to ints
#include <iostream>
#include <cstdlib>
using namespace std;

int main(int argc, char* argv[]) {
  for(int i = 1; i < argc; i++)
    cout << atoi(argv[i]) << endl;
} ///:~

Listado 3.57. C03/ArgsToInts.cpp


En este programa, se puede poner cualquier número de argumentos en la línea de comandos. Fíjese en que el bucle for comienza en el valor 1 para saltar el nombre del programa en argv[0]. También, si se pone un número en punto flotante que contenga un punto decimal en la línea de comandos, atoi() sólo toma los dígitos hasta el punto decimal. Si pone valores no numéricos en la línea de comandos, atoi() los devuelve como ceros.

El formato de punto flotante

La función printBinary() presentada anteriormente en este capítulo es útil para indagar en la estructura interna de varios tipos de datos. El más interesante es el formato de punto flotante que permite a C y C++ almacenar números que representan valores muy grandes y muy pequeños en un espacio limitado. Aunque los detalles no se pueden exponer completamente aquí, los bits dentro de los floats y doubles están divididos en tres regiones: el exponente, la mantisa, y el bit de signo; así almacena los valores utilizando notación científica. El siguiente programa permite jugar con ello imprimiendo los patrones binarios de varios números en punto flotante de modo que usted mismo pueda deducir el esquema del formato de punto flotante de su compilador (normalmente es el estándar IEEE para números en punto flotante, pero su compilador puede no seguirlo):

//: C03:FloatingAsBinary.cpp
//{L} printBinary
//{T} 3.14159
#include "printBinary.h"
#include <cstdlib>
#include <iostream>
using namespace std;

int main(int argc, char* argv[]) {
  if(argc != 2) {
    cout << "Must provide a number" << endl;
    exit(1);
  }
  double d = atof(argv[1]);
  unsigned char* cp = 
    reinterpret_cast<unsigned char*>(&d);
  for(int i = sizeof(double)-1; i >= 0 ; i -= 2){
    printBinary(cp[i-1]);
    printBinary(cp[i]);
  }
} ///:~

Listado 3.58. C03/FloatingAsBinary.cpp


Primero, el programa garantiza que se le haya pasado un argumento comprobando el valor de argc, que vale dos si hay un solo argumento (es uno si no hay argumentos, ya que el nombre del programa siempre es el primer elemento de argv). Si eso falla, imprime un mensaje e invoca la función exit() de la librería Estándar de C para finalizar el programa.

El programa toma el argumento de la línea de comandos y convierte los caracteres a double utilizando atof(). Luego el double se trata como un array de bytes tomando la dirección y moldeándola a un unsigned char*. Para cada uno de estos bytes se llama a printBinary() para mostrarlos.

Este ejemplo se ha creado para imprimir los bytes en un orden tal que el bit de signo aparece al principio - en mi máquina. En otras máquinas puede ser diferente, por lo que puede querer reorganizar el modo en que se imprimen los bytes. También debería tener en cuenta que los formatos de punto flotante no son triviales de entender; por ejemplo, el exponente y la mantisa no se alinean generalmente entre los límites de los bytes, en su lugar un número de bits se reserva para cada uno y se empaquetan en la memoria tan apretados como se pueda. Para ver realmente lo que está pasando, necesitaría averiguar el tamaño de cada parte del número (los bits de signo siempre son de un bit, pero los exponentes y las mantisas pueden ser de diferentes tamaños) e imprimir separados los bits de cada parte.

Aritmética de punteros

Si todo lo que se pudiese hacer con un puntero que apunta a un array fuese tratarlo como si fuera un alias para ese array, los punteros a arrays no tendrían mucho interés. Sin embargo, los punteros son mucho más flexibles que eso, ya que se pueden modificar para apuntar a cualquier otro sitio (pero recuerde, el identificador del array no se puede modificar para apuntar a cualquier otro sitio).

La aritmética de punteros se refiere a la aplicación de alguno de los operadores aritméticos a los punteros. La razón por la cual la aritmética de punteros es un tema separado de la aritmética ordinaria es que los punteros deben ajustarse a restricciones especiales de modo que se comporten apropiadamente. Por ejemplo, un operador común para utilizar con punteros es ++, que «añade uno al puntero». Lo que esto significa en realidad es que el puntero se cambia para moverse al «siguiente valor», signifique eso lo que signifique. A continuación, un ejemplo:

//: C03:PointerIncrement.cpp
#include <iostream>
using namespace std;

int main() {
  int i[10];
  double d[10];
  int* ip = i;
  double* dp = d;
  cout << "ip = " << (long)ip << endl;
  ip++;
  cout << "ip = " << (long)ip << endl;
  cout << "dp = " << (long)dp << endl;
  dp++;
  cout << "dp = " << (long)dp << endl;
} ///:~

Listado 3.59. C03/PointerIncrement.cpp


Para una ejecución en mi máquina, la salida es:

ip = 6684124
ip = 6684128
dp = 6684044
dp = 6684052

Lo interesante aquí es que aunque la operación ++ parece la misma tanto para el int* como para el double*, se puede comprobar que el puntero de int* ha cambiado 4 bytes mientras que para el double* ha cambiado 8. No es casualidad que éstos sean los tamaños de int y double en esta máquina. Y ése es el truco de la aritmética de punteros: el compilador calcula la cantidad apropiada para cambiar el puntero de modo que apunte al siguiente elemento en el array (la aritmética de punteros sólo tiene sentido dentro de los arrays). Esto funciona incluso con arrays de structs:

//: C03:PointerIncrement2.cpp
#include <iostream>
using namespace std;

typedef struct {
  char c;
  short s;
  int i;
  long l;
  float f;
  double d;
  long double ld;
} Primitives;

int main() {
  Primitives p[10];
  Primitives* pp = p;
  cout << "sizeof(Primitives) = " 
       << sizeof(Primitives) << endl;
  cout << "pp = " << (long)pp << endl;
  pp++;
  cout << "pp = " << (long)pp << endl;
} ///:~

Listado 3.60. C03/PointerIncrement2.cpp


La salida en esta máquina es:

sizeof(Primitives) = 40
pp = 6683764
pp = 6683804

Como puede ver, el compilador también hace lo adecuado para punteros a structs (y con class y union).

La aritmética de punteros también funciona con los operadores --, + y -, pero los dos últimos están limitados: no se pueden sumar dos punteros, y si se restan punteros el resultado es el número de elementos entre los dos punteros. Sin embargo, se puede sumar o restar un valor entero y un puntero. A continuación, un ejemplo que demuestra el uso de la aritmética de punteros:

//: C03:PointerArithmetic.cpp
#include <iostream>
using namespace std;

#define P(EX) cout << #EX << ": " << EX << endl;

int main() {
  int a[10];
  for(int i = 0; i < 10; i++)
    a[i] = i; // Give it index values
  int* ip = a;
  P(*ip);
  P(*++ip);
  P(*(ip + 5));
  int* ip2 = ip + 5;
  P(*ip2);
  P(*(ip2 - 4));
  P(*--ip2);
  P(ip2 - ip); // Yields number of elements
} ///:~

Listado 3.61. C03/PointerArithmetic.cpp


Comienza con otra macro, pero ésta utiliza una característica del preprocesador llamada stringizing (implementada mediante el signo # antes de una expresión) que toma cualquier expresión y la convierte a un array de caracteres. Esto es bastante conveniente, ya que permite imprimir la expresión seguida de dos puntos y del valor de la expresión. En main() puede ver lo útil que resulta este atajo.

Aunque tanto la versión prefijo como sufijo de ++ y -- son válidas para los punteros, en este ejemplo sólo se utilizan las versiones prefijo porque se aplican antes de dereferenciar el puntero en las expresiones anteriores, de modo que permiten ver los efectos de las operaciones. Observe que sólo se suman y restan valores enteros; si se combinasen de este modo dos punteros, el compilador no lo permitiría.

Aquí se ve la salida del programa anterior:

*ip: 0
*++ip: 1
*(ip + 5): 6
*ip2: 6
*(ip2 - 4): 2
*--ip2: 5

En todos los casos, el resultado de la aritmética de punteros es que el puntero se ajusta para apuntar al «sitio correcto», basándose en el tamaño del tipo de los elementos a los que está apuntando.

Si la aritmética de punteros le sobrepasa un poco al principio, no tiene por qué preocuparse. La mayoría de las veces sólo necesitará crear arrays e indexarlos con [], y normalmente la aritmética de punteros más sofisticada que necesitará es ++ y --. La aritmética de punteros generalmente está reservada para programas más complejos e ingeniosos, y muchos de los contenedores de la Librería Estándar de C++ esconden muchos de estos inteligentes detalles, por lo que no tiene que preocuparse de ellos.



[45] (N. del T.) zero indexing

[46] A menos que tome la siguiente aproximación estricta: «todo el paso de argumentos en C/C++ es por valor, y el «valor» de un array es el producido por su identificador: su dirección». Eso puede parecer correcto desde el punto de vista del lenguaje ensamblador, pero yo no creo que ayude cuando se trabaja con conceptos de alto nivel. La inclusión de referencias en C++ hace que el argumento «todo se pasa por valor» sea más confuso, hasta el punto de que siento que es más adecuado pensar en términos de «paso por valor» vs «paso por dirección».