El asombroso caso del objeto Fantasma
FENÓMENOS EXTRAÑOS
El asombroso caso del objeto Fantasma
2025-04-25
Por
Carolyn Lightrun

El caso que nos ocupa en esta ocasión lo he sacado de una pregunta de Stack Overflow Español en el que se planteaba como era posible ejecutar un método sobre un objeto que no ha sido instanciado… Es decir, sobre un objeto que no existe… Un objeto fantasma.
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.


SOBRE Carolyn Lightrun
Carolyn posee una sensibilidad especial para entran en contacto con el más allá. Cuando programa, las cosas más extrañas ocurren y Carolyn ha dedicado su carrera a investigar estos inquietantes fenómenos y encontrar la explicación lógica tras ellos.

 
Tu publicidad aquí :)