3.8.5. Arrays

Los vectores 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 vector, se utiliza la misma sintaxis de corchetes que se utiliza para definir el vector:

a[5] = 47;

Sin embargo, debe recordar que aunque el tamaño de a es 10, se seleccionan los elementos del vector comenzando por cero (esto se llama a veces indexado a cero[42], de modo que sólo se pueden seleccionar los elementos del vector 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 vectores son extremadamente rápidos, Sin embargo, si se indexa más allá del final del vector, no hay ninguna red de seguridad - se entrará en otras variables. La otra desventaja es que se debe definir el tamaño del vector 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 vector dinámicamente, pero es significativamente más sucia). El vector de C++ presentado en el capítulo anterior, proporciona un objeto parecido al vector que se redimensiona automáticamente , de modo que es una solución mucho mejor si el tamaño del vector no puede conocer en tiempo de compilación.

Se puede hacer un vector 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 como el identificador de struct i es independiente del i del bucle for.

Para comprobar que cada elemento del vector es contiguo con el siguiente, puede imprimir la dirección 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. Esto significa, que están colocados uno a continuación del otro.

Punteros y arrays

El identificador de un vector es diferente de los identificadores de las variables comunes. Un identificador de un vector no es un lvalue; no se le puede asignar nada. En realidad es FIXME:gancho dentro de la sintaxis de corchetes, y cuando se usa el nombre de un vector, sin los corchetes, lo que se obtiene es la dirección inicial del vector:

//: 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 las misma.

De modo que una manera de ver el identificador de un vector es como un puntero de sólo lectura al principio de éste. Y aunque no se pueda hacer que el identificador del vector apunte a cualquier otro sitio, se puede crear otro puntero y utilizarlo para moverse dentro del vector. 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 vector produzca su dirección de inicio resulta bastante importante cuando hay que pasar un vector a una función. Si declara un vector como un argumento de una función, lo que realmente está declarando es un puntero. De modo que en el siguiente ejemplo, fun1() y func2() tienen 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 vectores no se pueden pasados por valor[43], es decir, que nunca se puede obtener automáticamente una copia local del vector que se pasa a una función. Por eso, cuando se modifica un vector, 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 que print() utiliza la sintaxis de corchetes para los argumentos de tipo vector. Aunque la sintaxis de puntero y la sintaxis de corchetes efectivamente es la mismo cuando se están pasando vectores como argumentos, la sintaxis de corchetes deja más clara al lector que se pretende enfatizar que dicho argumento es un vector.

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

Los vectores pueden ser de cualquier tipo, incluyendo vectores de punteros. De hecho, cuando se quieren pasar argumentos de tipo línea de comandos dentro del 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 vector, que es el segundo argumento. El segundo argumento es siempre un vector de char*, porque los argumentos se pasan desde la línea de comandos como vectores de caracteres (y recuerde, un vector 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 vector. El siguiente programa imprime todos los argumentos de línea de comandos recorriendo el vector:

//: 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 vector de argumentos del programa, de modo que un error común al recoger argumentos de línea de comandos es tomar argv[0] como si fuera el primer argumento.

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 respeta). También, hay un modo alternativo de declarar argv:

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

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

Todo lo que se obtiene de la línea de comandos son vectores de caracteres; si quiere tratar un argumento como algún otro tipo, ha de convertirlos dentro del programa. Para facilitar la conversión a números, hay algunas funciones 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 vector 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 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 decimal 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 expuestos, 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 vector 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 re-organizar el modo en que se imprimen los bytes. También debería tener cuidado porque los formatos en punto-flotante no son tan 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 lo que esta pasando, necesitaría averiguar el tamaño de cada parte del número (los bit 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 vector fuese tratarlo como si fuera un alias para ese vector, los punteros a vectores 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 vector 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. Las 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 cláusulas especiales de modo que se comporten apropiadamente. Por ejemplo, un operador común para utilizar con punteros es ++, lo que "añade uno al puntero." Lo que de hecho significa esto es que el puntero se cambia para moverse al "siguiente valor," Lo que sea que ello 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 coincidencia, que estos sean los tamaños de int y double en esta máquina. Y ese 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 vector (la aritmética de punteros sólo tiene sentido dentro de los vectores). Esto funciona incluso con vectores 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 puede 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 demostrando 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 esta 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 vector 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 referenciar el puntero en las expresiones anteriores, de modo que permite ver los efectos en las operaciones. Observe que se han sumado y restado 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á apuntado.

Si la aritmética de punteros le sobrepasa un poco al principio, no tiene porqué preocuparse. La mayoría de las veces sólo la necesitará para crear vectores 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 en la librería de Estándar C++ esconden muchos de estos inteligentes detalles, por lo que no tiene que preocuparse de ellos.



[42] (N. de T.) zero indexing

[43] A menos que tome la siguiente aproximación estricta: «todos los argumentos pasado en C/C++ son por valor, y el «valor» de un vector 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».