Introducción a la programación genérica (II)

Artículos de la serie:

Clases, atributos y funciones miembro


El objetivo de este post es el de resumir brevemente algunos aspectos básicos del diseño de clases en C++. Como sabemos, el concepto central de la programación orientada a objetos es el de la clase, que actúa como unidad básica de encapsulamiento. Podemos pensar en ella como un prototipo que modela los atributos y las operaciones que pueden realizarse sobre cualquier instancia (objeto) de la misma [1, 2]. Según el problema planteado en el primer post de la serie, deseamos introducir una nueva clase Complex tal que cada uno de sus objetos represente un número complejo potencialmente distinto:

   class Complex {    private:       double re_, // parte real              im_; // parte imaginaria    public:       // constructor no-explícito:       constexpr Complex(double re = 0.0double im = 0.0noexcept          : re_{re}, im_{im} // lista de inicializadores       {           // cuerpo vacío del constructor   }       // resto de la interfaz pública (por definir)    };

Los modificadores de acceso public y private [3] empleados en la clase definen el grado de protección de sus miembros. Las funciones miembro públicas (en particular, el constructor o constructores) forman parte de la interfaz pública de la clase, esto es, las operaciones que un usuario puede realizar sobre los objetos de la clase. Por contra, los miembros privados (sean éstos atributos o funciones miembro) sólo pueden ser accedidos desde el interior de la clase y por sus funciones/clases amigas, siendo inaccesibles desde cualquier otro lugar del código. Así, una asignación como la indicada en el siguiente ejemplo produciría un error de compilación, puesto que el atributo re_ ha sido declarado privado:

   auto z = Complex{3.0, 2.0}; // inicializamos el número complejo z = (3,2)
   z.re_ = 1.0; // error de compilación: re_ es privado


Se considera una buena práctica el finalizar los nombres de los atributos privados de una clase con un guión bajo, con el fin de distinguirlos de otras variables locales (véase, por ejemplo, la guía de estilo [4]). Asimismo, se desaconseja el uso de notación húngara [5] en lenguajes fuertemente tipados como C++.

Analicemos la forma en la que ha sido definido el constructor de la clase:

   constexpr Complex(double re = 0.0double im = 0.0noexcept       : re_{re}, im_{im} // lista de inicializadores    { /* cuerpo vacío del constructor */ }

Éste crea una instancia del tipo Complex inicializando los atributos privados re_ e im_ (ambos de tipo double) de acuerdo con los valores remitidos como argumentos. Tal asignación se realiza a través de una lista de inicializadores de datos miembro, precedida por dos puntos. Al no existir precondiciones sobre los argumentos ni tener que realizarse operaciones adicionales, el cuerpo del constructor (entre llaves {}) se encuentra vacío. En este caso, se trata de un constructor con parámetros con valores por defecto: de no proporcionarse valores para la parte real y/o imaginaria del número complejo, éstas son inicializadas a cero automáticamente. A modo de ejemplo:

   auto a = Complex{};    // a.re_ = a.im_ = 0.0 por defecto 
   auto b = Complex{3.0}; // b.re_ = 3.0, b.im_ = 0.0 por defecto


El especificador noexcept [6] informa que el constructor no emitirá excepciones bajo ninguna circunstancia, puesto que las operaciones involucradas en la construcción de un objeto (dos inicializaciones de variables de tipo double) no pueden dar lugar al lanzamiento de las mismas. Este especificador permite al compilador realizar ciertas optimizaciones en el código, por lo que su uso es recomendable allá donde sea aplicable. Finalmente, el especificador constexpr [7] declara explícitamente que es posible evaluar el constructor en tiempo de compilación si los argumentos pasados a la función y el objeto resultante son conocidos al compilar. Su uso podría omitirse en las primeras fases de prototipado de nuestra clase.

Los atributos privados re_ e im_ definen la estructura interna de la clase. A continuación, procederemos a enriquecer su interfaz pública mediante funciones miembro que permitan acceder y modificar las partes real e imaginaria de un número complejo, así como calcular el cuadrado de su norma, su argumento o su complejo conjugado:

   class Complex {       double re_,              im_;    public:       constexpr Complex(double re = 0.0double im = 0.0noexcept          : re_{re}, im_{im} { }       // acceso:       constexpr auto real() const noexcept -> double { return re_; }       constexpr auto imag() const noexcept -> double { return im_; }       constexpr void real(double re) noexcept { re_ = re; }       constexpr void imag(double im) noexcept { im_ = im; }       // norma al cuadrado, complejo conjugado y argumento:       constexpr auto norm() const noexcept -> double  { return re_*re_ + im_*im_; }       constexpr auto conj() const noexcept -> Complex { return {re_, -im_}; }                 auto arg()  const noexcept -> double  { return std::atan2(im_, re_); }    };

Observemos que algunas de las funciones miembro se encuentran sobrecargadas (tal es el caso de real e imag), es decir, comparten el mismo nombre pero difieren en el número de argumentos, sus tipos y/o la presencia de calificadores const-volatile.

Las funciones miembro declaradas constantes a través del calificador const [8] no pueden modificar los objetos para los que son invocadas. Más concretamente, no pueden alterar los atributos no-estáticos de la clase, ni llamar a otras funciones miembro no constantes. Obviamente, los objetos declarados constantes sólo podrán ejecutar las funciones miembro constantes con el fin de garantizar su inmutabilidad lógica (de lo contrario, se produciría un error de compilación). Por su parte, los objetos no-constantes podrán invocar tanto funciones miembro constantes como no-constantes. Así lo demuestra el código siguiente:

   auto p = Complex{1.0, 3.0};  // p = (1,3) es objeto no-constante    p.real(5.5);                 // llamada a sobrecarga no-constante real(): p = (5.5,3)    auto i = p.imag();           // llamada a sobrecarga constante imag(): i = 3.0 double    auto c = p.conj();           // llamada a método constante conj(): c = (5,-3) Complex    auto const q = Complex{2.0, 4.0}; // q = (2,4) es objeto constante    auto n = q.norm();           // llamada a método constante norm(): n = 20.0 double    q.imag(7.3); // llamada a método no-constante imag() // error de compilación: asignación a una entrada de sólo lectura


Referencias bibliográficas:
  1. Stroustrup B., 'The C++ Programming Language'. Addison-Wesley, 4th Edition (2013).
  2. Ceballos Sierra F.J., Enciclopedia del Lenguaje C++. Ra-Ma, 2ª Edición (2009).
  3. Modificadores de acceso - https://en.cppreference.com/w/cpp/language/access
  4. Guía de estilo de Google - https://google.github.io/styleguide/cppguide.html
  5. Notación húngara - https://en.wikipedia.org/wiki/Hungarian_notation
  6. noexcept - https://en.cppreference.com/w/cpp/language/noexcept_spec
  7. constexpr - https://en.cppreference.com/w/cpp/language/constexpr
  8. const specifier - https://docs.microsoft.com/es-es/cpp/cpp/const-cpp?view=vs-2019

Puedes acceder al siguiente artículo de la serie a través de este enlace.

No hay comentarios:

Publicar un comentario