Constructores: por defecto, paramétricos y de copia

Ya hemos visto constructores en el capítulo sobre estructuras (véase la sección Constructores y destructores). Para las clases, funcionan de forma muy parecida. Volvamos a los puntos principales y analicemos otras características.

Un constructor es un método que tiene el mismo nombre que la clase y es de tipo void, lo que significa que no devuelve ningún valor. Normalmente, la palabra clave void se omite delante del nombre del constructor. Una clase puede tener varios constructores, que deben diferir en el número o tipo de parámetros. Cuando se crea un nuevo objeto, el programa llama al constructor para poder establecer los valores iniciales de los campos.

Una de las formas de crear un objeto que hemos utilizado es la descripción en el código de la variable de la clase correspondiente. Se llamará al constructor en esta cadena; ocurre automáticamente.

En función de la presencia y los tipos de parámetros, los constructores se dividen en:

  • constructor por defecto: sin parámetros;
  • constructor de copia: con un único parámetro que es el tipo de una referencia a un objeto de la misma clase;
  • constructor paramétrico: con un conjunto arbitrario de parámetros, excepto una única referencia para la copia mostrada anteriormente.

 

Constructor por defecto

El constructor más sencillo, sin parámetros, se denomina constructor por defecto. A diferencia de C++, MQL5 no considera constructor por defecto a un constructor que tiene parámetros y todos ellos tienen valores por defecto (es decir, todos los parámetros son opcionales; véase la sección Parámetros opcionales).

Vamos a definir un constructor por defecto para la clase Shape.

class Shape
{
   ...
public:
   Shape()
   {
      ...
   }
};

Por supuesto, ello debe hacerse en la sección pública de la clase.

Los constructores a veces se hacen protegidos o privados de forma deliberada para controlar cómo se crean los objetos, por ejemplo, a través de métodos de fábrica. Pero en este caso estamos considerando la versión estándar de la composición de clases.

Para establecer los valores iniciales de las variables de los objetos podemos utilizar las sentencias de asignación habituales:

public:
   Shape()
   {
      x = 0;
      y = 0;
      ...
   }

No obstante, la sintaxis del constructor ofrece otra opción. Se denomina lista de inicialización y se escribe después del encabezado de la función, separada por dos puntos. La lista en sí es una secuencia de nombres de campo separados por comas, con el valor inicial deseado entre paréntesis a la derecha de cada nombre.

Por ejemplo, para el constructor Shape se puede escribir de la siguiente manera:

public:
   Shape() :
      x(0), y(0),
      backgroundColor(clrNONE)
   {
   }

Esta sintaxis es preferible a la asignación de variables en el cuerpo de un constructor por varias razones.

En primer lugar, la asignación en el cuerpo de la función se realiza después de haber creado la variable correspondiente. Dependiendo del tipo de variable, esto puede significar que primero se llamó al constructor por defecto para ella y luego se sobrescribió el nuevo valor (y esto supone gastos adicionales). En el caso de una lista de inicialización, la variable se crea inmediatamente con el valor deseado. Es probable que el compilador pueda optimizar la asignación en ausencia de una lista de inicialización, pero en el caso general, esto no está garantizado.

En segundo lugar, algunos campos de clase pueden declararse con el modificador const . Entonces sólo se pueden establecer en la lista de inicialización.

En tercer lugar, las variables de campo de tipos definidos por el usuario pueden no tener un constructor por defecto (es decir, todos los constructores disponibles en su clase tienen parámetros). Esto significa que, cuando se crea una variable, es necesario pasarle parámetros reales, y la lista de inicialización le permite hacerlo: los valores de los argumentos se especifican dentro de paréntesis, como si se tratara de una llamada explícita al constructor. Se puede utilizar una lista de inicialización en las definiciones de los constructores, pero no en otros métodos.

 

Constructor paramétrico

Un constructor paramétrico, por definición, tiene múltiples parámetros (uno o más).

Por ejemplo, imaginemos que para las coordenadas x y y se describe una estructura especial con un constructor paramétrico:

struct Pair
{
   int xy;
   Pair(int aint b): x(a), y(b) { }
};

Entonces podemos utilizar el campo coordinates del nuevo tipo Pair en lugar de los dos campos de enteros x y y de la clase Shape. Esta construcción de objetos se denomina inclusión o agregación compositiva. El objeto Pair es parte integrante del objeto Shape. Un par de coordenadas se crea y destruye automáticamente junto con el objeto «anfitrión».

Dado que Pair no tiene constructor sin parámetros, el campo coordinates debe especificarse en la lista de inicialización del constructor Shape, con dos parámetros (int, int):

class Shape
{
protected:
   // int x, y;
   Pair coordinates;  // center coordinates (object inclusion)
   ...
public:
   Shape() :
      // x(0), y(0),
      coordinates(00), //object initialization
      backgroundColor(clrNONE
   {
   }
};

Sin una lista de inicialización, estos objetos automáticos no pueden crearse.

Dado el cambio en cómo se almacenan las coordenadas en el objeto, necesitamos actualizar el método toString:

   string toString() const
   {
      return (string)coordinates.x + " " + (string)coordinates.y;
   }

Pero esta no es la versión final: pronto haremos algunos cambios más.

Recordemos que las variables automáticas se describieron en la sección Instrucciones de definición y declaración . Se llaman automáticas porque el compilador las crea (asigna memoria) automáticamente, y también las borra automáticamente cuando la ejecución del programa abandona el contexto (bloque de código) en el que se creó la variable.
 
En el caso de las variables de objeto, la creación automática significa no sólo la asignación de memoria, sino también una llamada al constructor. La eliminación automática de un objeto va acompañada de una llamada a su destructor (véase más adelante la sección Destructores). Además, si el objeto forma parte de otro objeto, entonces su vida útil coincide con la vida útil de su «propietario», como en el caso del campo coordinates: una instancia de Pair en el objeto Shape.
 
Los objetos estáticos (incluidos los globales) también son gestionados automáticamente por el compilador.
 
Una alternativa a la asignación automática es la creación y manipulación dinámica de objetos mediante punteros.

En la sección sobre herencia descubriremos cómo una clase puede heredarse de otra. En este caso, la lista de inicialización es la única forma de llamar al constructor paramétrico de la clase base (el compilador no es capaz de generar de forma automática una llamada al constructor con parámetros, como hace implícitamente para el constructor por defecto).

Añadamos otro constructor a la clase Shape que permita establecer valores específicos para las variables. Será simplemente un constructor paramétrico (puede crear tantos como quiera, para diferentes propósitos y con un conjunto diferente de parámetros).

   Shape(int pxint pycolor back) :
      coordinates(pxpy),
      backgroundColor(back)
   {
   }

La lista de inicialización asegura que cuando se ejecuta el cuerpo del constructor, todos los campos internos (incluyendo los objetos anidados, si los hay) ya han sido creados e inicializados.

El orden de inicialización de los miembros de la clase no se corresponde con la lista de inicialización, sino con la secuencia de su declaración en la clase.

Si se declara un constructor con parámetros en una clase y se requiere que permita la creación de objetos sin argumentos, el programador debe implementar explícitamente el constructor por defecto.

En el caso de que no haya ningún constructor en la clase, el compilador proporciona implícitamente un constructor por defecto en forma de función stub, que se encarga de inicializar los campos de los siguientes tipos: cadenas, arrays dinámicos y objetos automáticos con un constructor por defecto. Si no existen tales campos, el constructor implícito por defecto no hace nada. Los campos de otros tipos no se ven afectados por el constructor implícito, por lo que contendrán «basura» aleatoria. Para evitarlo, el programador debe declarar explícitamente el constructor y establecer los valores iniciales.

 

Constructor de copia

El constructor de copia permite crear un objeto a partir de otro objeto pasado por referencia como único parámetro.

Por ejemplo, para la clase Shape, el constructor de copia podría tener este aspecto:

class Shape
{
   ...
   Shape(const Shape &source) :
      coordinates(source.coordinates.xsource.coordinates.y),
      backgroundColor(source.backgroundColor)
   {
   }
   ...
};

Tenga en cuenta que los miembros protegidos y privados de otro objeto están disponibles en el objeto actual porque los permisos funcionan a nivel de clase. En otras palabras: dos objetos de la misma clase pueden acceder a los datos del otro cuando se les da una referencia (o puntero).

Si existe tal constructor, puede crear objetos utilizando uno de los dos tipos de sintaxis:

void OnStart()
{
   Shape s;
   ...
   Shape s2(s);   // ok: syntax 1 - copying
   Shape s3 = s;  // ok: syntax 2 - copying via initialization
                  //                   (if there is copy constructor)
                  //                 - or assignment
                  //                   (if there is no copy constructor,
                  //                    but there is default constructor)
   
   Shape s4;      // definition
   s4 = s;        // assignment, not copy constructor!
}

Es necesario distinguir entre la inicialización de un objeto durante la creación y la asignación.

La segunda opción (marcada con el comentario «sintaxis 2») funcionará aunque no exista constructor de copia, pero sí un constructor por defecto. En este caso, el compilador generará un código menos eficiente: primero, utilizando el constructor por defecto, creará una instancia vacía de la variable receptora (s3, en este caso), y luego copiará los campos de la muestra (s, en este caso) elemento a elemento. De hecho, se dará el mismo caso que con la variable s4, para la que la definición y la asignación se realizan mediante sentencias separadas.

Si no existe constructor de copia, al intentar utilizar la primera sintaxis se producirá un error de «conversión de parámetros no permitida», ya que el compilador intentará tomar algún otro constructor disponible con un conjunto diferente de parámetros.

Tenga en cuenta que si la clase tiene campos con el modificador const, la asignación de tales objetos está prohibida por razones obvias: un campo constante no se puede cambiar, sólo se puede establecer una vez al crear un objeto. Por lo tanto, el constructor de copia se convierte en la única forma de duplicar un objeto.

En concreto, en las secciones siguientes completaremos nuestro ejemplo Shape1.mq5 y el campo siguiente aparecerá en la clase Shape (con una cadena de descripción type). El operador de asignación generará errores (en particular, para líneas tales como con la variable s4):

attempting to reference deleted function
   'void Shape::operator=(const Shape&)'
function 'void Shape::operator=(const Shape&)' was implicitly deleted
   because member 'type' has 'const' modifier

Gracias a la detallada redacción del compilador se puede comprender la esencia y las razones de lo que ocurre: en primer lugar, se menciona el operador de asignación ('='), y no el constructor de copia; en segundo lugar, se informa de que el operador de asignación se ha eliminado de forma implícita debido a la presencia del modificador const. Aquí nos encontramos con conceptos aún desconocidos que estudiaremos más adelante: sobrecarga de operadores en las clases, conversión de tipo de objetoy la posibilidad de marcar métodos como suprimidos.

En la sección herencia, después de aprender a describir las clases derivadas, necesitamos hacer algunas aclaraciones sobre los constructores de copia en las jerarquías de clases.