Video al final del artículo o en nuestro canal de YouTube, para los que no os guste la lectura.
Respondí la pregunta en el foro, pero el tema me pareció lo suficientemente interesante para ser el objeto de un artículo para la sección de Fenómenos Extraños así que aquí estamos. Como ya sabéis todos los casos misteriosos y sobrenaturales nos cautivan y no podemos parar hasta que los hayamos estudiado y entendido. Así que sin más dilación veamos el código en cuestión (que bonito pareado nos ha quedado… anda otra vez! :) :
#include <iostream>
class A {
public:
void send() {
std::cout << "Método send ejecutado\n";
}
};
class B {
private:
A * a;
public:
B() {
std::cout << "Constructor Clase B\n";
a->send();
}
};
int main(int argc, char *argv[]) {
new B();
}Como podéis ver, el código crea un objeto de la clase B
en la función main de nuestro programa, y el constructor de
esa clase, ejecuta el método send en la instancia privada
a (un puntero a un objeto de la clase A) de la
clase B… Pero ese campo no ha sido inicializado. Así que
este programa debería producir un error verdad?. Veamos.
Si compilamos y ejecutamos este programa obtenemos lo siguiente:
$ make ghostobj g++ ghostobj.cpp -o ghostobj $ ./ghostobj Constructor Clase B Método send ejecutado
¿Cómo es esto posible?… Si. Misterioso e inquietante. Un resultado turbador. Enigmático. Obscuro. Insondable. Un claro caso para Fenómenos ParaNormales. Veamos que está pasando aquí.
Fundamentos de POO
Para entender lo que está ocurriendo debemos entender como funciona la programación orientada a objetos a bajo nivel y como los lenguajes de este tipo consiguen la funcionalidad que ofrece este paradigma de programación.
En POO, una clase no es más que una plantilla para crear objetos. Estos objetos pueden contener datos (variables, propiedades, miembros, llamadlos como queráis) y código (funciones, métodos, manejadores,…). Cuando creamos un objeto a partir de una clase estamos creando un bloque de memoria que va a contener los datos asociados con ese objeto. Bueno, algunas cosas más acaban en ese bloque de memoria, pero eso no es relevante para el tema que nos ocupa.
Esto está genial, pero es solo la mitad de la historia. ¿Qué ha pasado con el código de la clase?. Bueno, en general, el código de la clase es independiente del bloque de memoria que representa el objeto. Esto tiene sentido, puesto que el mismo código debe de poder utilizarse con multitud de objetos, o si lo preferís con distintos bloques de memoria. Así que lo que el compilador hace es un pequeño truco. A cada método de la clase le añade, silenciosamente, un parámetro extra que no es otra cosa que el puntero al bloque de datos asociado con un determinado objeto o instancia de clase si lo preferís. Voilá ya tenemos métodos.
La magia, que no es tan mágica la verdad, es que el lenguaje nos ofrece una forma alternativa, más fácil e intuitiva, de llamar a estas funciones especiales asociadas a una clase o métodos.
Así, en su forma más básica, el siguiente código C++:
#include <iostream>
class CLASE {
public:
int a;
void metodo () {
a++;
std::cout << a << std::endl;
}
};
int main () {
CLASE *obj = new CLASE();
obj->metodo ();
}Es equivalente a el siguiente código C:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int a;
} CLASE_Data;
void metodo (CLASE_Data *esto) {
esto->a++;
printf ("%d\n", esto->a);
}
int main () {
CLASE_Data *obj = malloc (sizeof (CLASE_Data));
metodo (obj);
}Ese parámetro que se añade automáticamente a cada método invocado
sobre un objeto y que nosotros hemos llamado esto recibe el
nombre de this en lenguajes como C++ o Java o
self en lenguajes como Python. En cualquier caso, no es más
que un puntero a los datos, puesto que el código es el mismo para todos
los objetos de la clase.
En lenguajes interpretados como Python los bloques de memoria asociados a cada objeto contienen mucho más que espacio para almacenar su estado ya que, estos lengajes, requieren de información adicional para poder hacer esas cosas que hacen.
Como podéis ver, cuando se trata de objetos y métodos, los lenguajes orientados a objetos no ofrecen una ventaja tan grande más allá de que hay que escribir un poco menos. Las ventajas de estos lenguajes son más evidentes cuando utilizamos otras carácteristicas de este paradigma de programación como la herencia o el polimorfismo. Estos también se pueden simular a más bajo nivel, pero la cosa ya se hace mucho más engorrosa.
Objetos fantasma
Ahora que sabemos como funciona la creación de objetos y la invocación de métodos, alguien podría decir porque el código del principio funciona?… Efectivamente, el código funciona por que el método que invocamos no utiliza el bloque de datos asociado al objeto
Veamos que está pasando. La clase B declara un bloque de datos que
contiene un puntero a un objeto de la clase A. Este puntero nunca se
inicializa, así que contendrá cualquier valor. Sin pérdida de
generalidad, imaginemos que ese valor es 0, pero puedes
sustituirlo por lo que te de la gana.
Cuando ejecutamos el método send sobre la
“instancia” inexistente de A, lo que estaremos ejecutando
es:
a->send () Que el compilador convierte por nosotros en lo siguiente:
send (a) Si el objeto fuera un puntero nulo (0x00 como hemos
dicho más arriba), a->send() se traduciría en la
siguiente instrucción.
((A*)0)->send();La se convierte por obra y gracia del compilador un una llamada de bajo nivel como la siguiente:
send ((A*)0);El mensaje se seguirá imprimiendo sin problemas. De la misma forma que si ejecutamos:
((A*)42)->send();Puesto que send no usa el puntero this el
método se ejecuta normalmente. De hecho esto es ejemplo de una mala
definición de clase, puesto que si el método no depende del objeto,
debería ser un método estático, si es que depende de la clase, o
simplemente una función independiente.
Que no me creéis?
Por si alguno no se cree todo esto que estamos contando, para a usar una versión más sencilla de nuestro programa de ejemplo. Este es el código:
#include <iostream>
class A {
public:
void send() {
std::cout << "Método send ejecutadoo\n";
}
};
int main(int argc, char *argv[]) {
A *a = new A();
a->send();
((A*)0)->send();
}Y ahora echemos un ojo a el código que ha generado el compilador para
las dos llamadas a send. Primero la llamada normal, usando
un objeto real. Esto es lo que nos muestra objdump:
$ objdump -d ghostobj4 | c++filt
0000000000001169 <main>:
(...)
117d: e8 ce fe ff ff call 1050 <operator new(unsigned long)@plt>
1182: 48 89 45 f8 mov %rax,-0x8(%rbp)
1186: 48 8b 45 f8 mov -0x8(%rbp),%rax
118a: 48 89 c7 mov %rax,%rdi
118d: e8 78 00 00 00 call 120a <A::send()>
NOTA:Hemos filtrado la salida de objdump usando
c++filt para convertir los nombres de los sí,bolos C++. De
lo contrario veríamos un montón de letras sin sentido, en lugar de
new o send
En el volcado anterior podemos ver la llamada al operador
new y como el resultado de la función, devuelto en el
registro rax se utiliza como primer parámetro (el cual debe
almacenarse en rdi) cuando llamamos al método
send. Lo que se corresponde con lo que explicamos en la
sección anterior.
Ahora veamos como es la llamada usando el valor 0
0000000000001169 <main>:
1192: bf 00 00 00 00 mov $0x0,%edi
1197: e8 6e 00 00 00 call 120a <A::send()>
Efectivamente, simplemente pasamos 0 como primer parámetro.
Liándola un poco
Ahora podemos utilizar todo esto que hemos aprendido para escribir código raro que… la verdad no creo que tenga ninguna utilidad, pero como dicen los anglosajones… why not?.
#include <iostream>
class A {
public:
long metodo1 (int a) {
return (long)this * a;
}
};
int main(int argc, char *argv[]) {
std::cout << ((A*)2)->metodo1(4) << std::endl;
std::cout <<((A*)42)->metodo1(2) << std::endl;
}El código anterior nos permite multiplicar dos valores de una forma
muy rara. Sin embargo, el uso de this en la función nos
puede dar una pista de que es lo que está pasando. Que os parece
esto?
#include <iostream>
class A {
int a;
public:
long metodo1 (int b) {
return a * b;
}
};
int main(int argc, char *argv[]) {
int p = 3;
std::cout << ((A*)&p)->metodo1(4) << std::endl;
}Ahora hemos ido un paso más allá. Como os dijimos, un objeto básico,
no es más que un bloque de memoria conteniendo los datos que definamos
en la clase. En este ejemplo hemos definido un entero con la variable
miembro a en la clase A. En este caso nuestro
objeto solo necesita espacio para almacenar un entero, así que si
pasamos un puntero a un entero, eso sería equivalente pasar un objeto de
la clase A.
Desde un punto de vista más C++, la llamada al método debería escribirse como:
std::cout << reinterpret_cast<A*>(&p)->metodo1(4) << std::endl;Lo cual podría parecer un poquito más fácil de leer, pero sigue siendo una expresión igual de fea… Cosas de este lenguaje.
Conclusión
Los lenguajes orientados a objetos no son mágicos y al fin y al cabo, tienen que almacenar los datos en algún sitio y ejecutar código que también debe estar en algún sitio. El ejemplo que hemos analizado es un caso límite que muestra donde estos dos puntos de vista de cruzan y nos ha permitido profundizar un poco más en como funcionan internamente los lenguajes orientados a objetos.
■
