4.9.18d Sobrecarga del operador [ ]
§1 Sinopsis
Recordemos (
4.9.16) que este operador sirve para señalar subíndices de matrices
simples y multidimensionales; de ahí su nombre, operador subíndice o de selección de miembro de matriz.
La expresión:
<exp1>[exp2]
se define como: *((exp1) + (exp2)) donde exp1 es un puntero y exp2 es un entero o viceversa.
Por ejemplo, arrX[3] se define como: *(arrX + 3) o *(3 + arrX), donde arrX es un puntero al primer elemento de la matriz. (arrX + 3) es un puntero al cuarto elemento, y *(arrX + 3) es el valor del cuarto elemento de la matriz.
Lo anterior puede sintetizarse en la siguiente relación:
arrX[3] == *(arrX + 3) // §1a
§2 Cuando se utiliza con tipos definidos por el usuario, este operador puede ser sobrecargado mediante la
función operator[ ]( ) (
4.9.18). Para ilustrarlo con un ejemplo, utilizaremos la clase
mVector que contiene una matriz (es una matriz de vectores), y suponemos que los elementos de la matriz son vectores
deslizantes de un espacio bidimensional. El diseño básico es el que se indica:
class Vector { // definición de la clase Vector
public: int x, y;
};
class mVector { // definición de la clase mVector
public:
Vector* mVptr; // L.6:
mVector(int n = 1) { // constructor por defecto
mVptr = new Vector[n]; // L.8:
}
~mVector() { // destructor
delete [] mVptr;
}
};
Comentario
La clase Vector tiene solo dos miembros, que suponemos las componentes escalares de cada vector del plano. Por simplicidad hemos supuesto que son int, pero podrían ser otros tipos de punto flotante, por ejemplo float o double. Esta clase auxiliar la hemos definido externa e independiente de la clase mVector.
También podría utilizarse otro diseño en el que Vector estuviese definida "dentro" de la clase mVector.
Las diferencias entre ambos y los criterios de uso se discuten en (
4.13.2):
class mVector { // definición de la clase mVector
...
class Vector { // clase anidada
...
};
...
};
La clase mVector tiene un solo miembro; un puntero-a-Vector mVptr (L.6). También definimos un constructor por
defecto y un destructor.
Observe (L.8) que el constructor del objeto tipo mVector, crea una matriz de objetos tipo Vector del tamaño
indicado en el argumento (1 por defecto) y la señala con el puntero mVptr. Esta matriz está alojada en memoria persistente
( 4.9.20c) y en cierta forma podríamos
pensar que es "externa" al objeto, ya que este realmente solo contiene un puntero [1].
Precisamente en razón de esta persistencia, el destructor debe rehusar la memoria asignada a la matriz, pues de otro modo este
espacio se perdería al ser destruido el objeto (
4.9.21).
Siguiendo el paradigma de la POO, esta clase deberá contener los datos (la matriz) y los algoritmos (métodos) para manejarla. Deseamos utilizar los objetos de tipo mVector como auténticas matrices, por lo que deberíamos poder utilizarlos con álgebra de matrices C++. Utilizando una analogía, si por ejemplo m es una matriz de enteros, sabemos que el lenguaje nos permite utilizar las expresiones siguientes:
m[i];
// L.1: acceso a elemento con el operador subíndice
int x = m[i]; // L.2: asignación a un miembro de la clase int
m[i] = m[j]; // L.3: asignación a miembro
m[i] = 3 * m[j]; // L.4: producto por un escalar
m[i] = m[j] * m[k]; // L.5: producto entre miembros
En consecuencia, debemos preparar el diseño de la clase mVector de forma que que pueda mimetizarse el comportamiento anterior con sus objetos. Es decir, deben permitirse las siguientes expresiones:
mVector m1;
m1[i];
// acceso a elemento con el operador subíndice
Vector v1 = m1[i]; // asignación a un miembro de la clase Vector
m1[i] = m1[j]; // asignación a miembro
m1[i] = 3 * m1[j]; // producto por un escalar
m1[i] = m1[j] * m1[k]; // producto entre miembros
§3 Operador subíndice
Para mimetizar este comportamiento con los objetos de la nueva clase empezaremos por poder referenciarlos mediante el operador
subíndice [ ] (L.1 ). Este operador debe recibir un
int y devolver el miembro correspondiente de la matriz (recordemos que los miembros son tipo Vector). Como sabemos
que debe gozar del doble carácter de Rvalue y Lvalue (L.3
),
deducimos que debe devolver una referencia (
4.9.18c). A "vote pronto" podría parecernos que la definición debe
ser del tipo:
const size_t sV = sizeof(Vector);
Vector& operator[](int i) { return *( mVptr + (i * sV)); }
sin embargo, reflexionando más detenidamente recordamos que mVptr está definido precisamente como puntero-a-Vector,
por lo que su álgebra lleva implícito el tamaño de los objetos Vector
( 4.2.2), lo que significa que
podemos prescindir del factor sV:
Vector& operator[](int i) { return *( mVptr + i ); }
Recordando la definición de subíndice §1a ,
y la relación entre punteros y matrices (
4.3.2) la expresión anterior equivale a:
Vector& operator[](int i) { return mVptr[i]; }
esta es justamente la definición que utilizamos para la función-operador operator[ ] (
L.30) de nuestra clase. Como resultado, podemos utilizar
expresiones del tipo:
mVector mV1(5); // objeto tipo mVector (matriz de 5 Vectores)
mV1[2]; // tercer elemento de la matriz
§4 Operador de asignación
Para utilizar el operador de asignación = con los objetos devueltos por el selector de miembro [ ], debemos
sobrecargarlo para los objetos tipo Vector. Esto se ha visto en el epígrafe correspondiente, por lo que nos limitamos a
copiar dicha definición (
4.9.18a):
Vector& operator= (const Vector& v) { // función operator=
x = v.x; y = v.y;
return *this;
}
Su implementación en la versión definitiva (
L.6), nos permite utilizar expresiones del tipo:
Vector v1;
v1 = mV1[0]; // M.6:
§5 Producto por un escalar
Para mimetizar el comportamiento expresado en L.4
, sobrecargamos el operador producto para la clase
Vector de forma que acepte un int. La definición la hacemos de forma que corresponda a la definición tradicional. Es
decir, la resultante es un vector cuyos componentes son el producto de los componentes x y del vector operando por el
escalar.
Es importante observar aquí que, en el caso de la matriz de enteros m, las dos sentencias siguientes son equivalentes:
m[i] = m[j] * 3; // producto por un escalar (por la derecha)
m[i] = 3 * m[j]; // producto por un escalar (por la izquierda)
§5.1 Esto significa que debemos definir el producto en ambos sentidos. Para el primero podemos
definir una función miembro que acepte un argumento tipo int (además del correspondiente puntero this). Este método
tiene el aspecto que se indica:
Vector operator* (int i) { // producto por un escalar (por la derecha)
Vector vr;
vr.x = x * i;
vr.y = y * i;
return vr;
}
Después de implementado en la versión definitiva (
L.9), nos permite expresiones del tipo:
mV1[4] = mV1[0] * 5; // M.8:
§5.2 El producto por la izquierda debemos definirlo como una función-operador externa. Se trata de una función
independiente (no pertenece a una clase) que acepta dos argumentos, un int y un Vector. Como es usual, la declaramos
friend de la clase Vector (
L.15) para que pueda tener acceso a sus miembros (aunque en este caso no es necesario porque todos son públicos). Su diseño es muy
parecido al anterior, aunque en este caso no existe puntero implícito this y debemos referenciar el objeto Vector
directamente:
Vector operator* (int i, Vector v) {
Vector vr;
vr.x = v.x * i;
vr.y = v.y * i;
return vr;
}
Su implementación (
L.38) hace posible expresiones como:
mV1[2] = 5 * mV1[0]; // M.11:
§6 Una vez introducidas todas las modificaciones anteriores en la versión básica
, el diseño resultante es el siguiente:
#include <iostream>
using namespace std;
class Vector { // definición de clase Vector
public: int x, y;
Vector& operator= (const Vector& v) { // L.6: asignación V = V
x = v.x; y = v.y; return *this;
}
Vector operator* (int i) { // L.9: Producto V * int
Vector vr;
vr.x = x * i; vr.y = y * i;
return vr;
}
void showV();
friend Vector operator* (int, Vector); // L.15: Producto int * V
};
void Vector::showV() { cout << "X = " << x << "; Y = " << y << endl; }
class mVector { // definición de clase mVector
int dimension;
// L.20:
public:
Vector* mVptr;
mVector(int n = 1) { // constructor por defecto
dimension = n;
mVptr = new Vector[dimension]; // L.25:
}
~mVector() {
// destructor
delete [] mVptr;
}
Vector& operator[](int i) { return mVptr[i]; }
void showmem (int); // L.31:
};
void mVector::showmem (int i) { // L.34:
if((i >= 0) && (i <= dimension)) mVptr[i].showV();
else cout << "Argumento incorrecto! pruebe otra vez" << endl;
}
Vector operator* (int i, Vector v) { // L.38:
Vector vr;
vr.x = v.x * i; vr.y = v.y * i;
return vr;
}
void main() { // =====================
mVector mV1(5); // M.1:
mV1[0].x = 2; mV1[0].y = 3;
mV1.showmem(0);
Vector v1;
v1 = mV1[0]; // M.6:
v1.showV();
mV1[4] = mV1[0] * 5; // M.8:
mV1.showmem(4);
mV1[2] = 5 * mV1[0]; // M.11:
mV1.showmem(2);
}
Salida:
X = 2; Y = 3
X = 2; Y = 3
X = 10; Y = 15
X = 10; Y = 15
Comentario
En un programa real, se debería implementar un mecanismo de control de excepciones que pudiera controlar la posibilidad de que
el operador new del constructor (L.25) fuese incapaz de crear el objeto
( 4.9.20). Es
decir, controlar que operaciones como la de M.1 concluyen con éxito.
Para manejar convenientemente los límites incluimos en mVector el miembro dimension (L.20); su valor es iniciado por el constructor (L.24 ) y acompaña a cada instancia. El efecto es que es posible implementar la interfaz de la clase de forma que el usuario no pueda acceder un elemento fuera del espacio de la matriz.
Para facilitar la lectura incluimos en la mVector el método showmem (L.31) que muestra los componentes de un elemento de la matriz. Este método utiliza el miembro dimension para verificar que no pretendemos acceder a un elemento fuera de los límites del objeto previamente creado.
[1] Naturalmente esto depende del nivel de abstracción que se maneje. A ciertos efectos puede pensarse
en la estructura como un único objeto (esta es precisamente una de las ventajas de la POO
1.1), mientras que en otros casos hay que
tener muy en cuenta esta circunstancia. Por ejemplo si necesitamos una rutina que escriba y lea estos objetos a ficheros en disco.