7.5. Elección entre sobrecarga y argumentos por defecto

Tanto la sobrecarga de funciones como los argumentos por defecto resultan útiles para ponerle nombre a las funciones. Sin embargo, a veces puede resultar confuso saber qué técnica utilizar. Por ejemplo, estudie la siguiente herramienta que está diseñada para tratar automáticamente bloques de memoria:

//: C07:Mem.h
#ifndef MEM_H
#define MEM_H
typedef unsigned char byte;

class Mem {
  byte* mem;
  int size;
  void ensureMinSize(int minSize);
public:
  Mem();
  Mem(int sz);
  ~Mem();
  int msize();
  byte* pointer();
  byte* pointer(int minSize);
}; 
#endif // MEM_H ///:~

Listado 7.9. C07/Mem.h


El objeto Mem contiene un bloque de octetos y se asegura de que tiene suficiente memoria. El constructor por defecto no reserva memoria pero el segundo constructor se asegura de que hay sz octetos de memoria en el objeto Mem. El destructor libera la memoria, msize() le dice cuántos octetos hay actualmente en Mem y pointer() retorna un puntero al principio de la memoria reservada (Mem es una herramienta a bastante bajo nivel). Hay una versión sobrecargada de pointer() que los programadores clientes pueden utilizar para obtener un puntero que apunta a un bloque de memoria con al menos el tamaño minSize, y el método lo asegura.

El constructor y el método pointer() utilizan el método privado ensureMinSize() para incrementar el tamaño del bloque de memoria (note que no es seguro mantener el valor de retorno de pointer() si se cambia el tamaño del bloque de memoria).

He aquí la implementación de la clase:

//: C07:Mem.cpp {O}
#include "Mem.h"
#include <cstring>
using namespace std;

Mem::Mem() { mem = 0; size = 0; }

Mem::Mem(int sz) {
  mem = 0;
  size = 0;
  ensureMinSize(sz); 
}

Mem::~Mem() { delete []mem; }

int Mem::msize() { return size; }

void Mem::ensureMinSize(int minSize) {
  if(size < minSize) {
    byte* newmem = new byte[minSize];
    memset(newmem + size, 0, minSize - size);
    memcpy(newmem, mem, size);
    delete []mem;
    mem = newmem;
    size = minSize;
  }
}

byte* Mem::pointer() { return mem; }

byte* Mem::pointer(int minSize) {
  ensureMinSize(minSize);
  return mem; 
} ///:~

Listado 7.10. C07/Mem.cpp


Puede observar que ensureMinSize() es la única función responsable de reservar memoria y que la utilizan tanto el segundo constructor como la segunda versión sobrecargada de pointer(). Dentro de ensureSize() no se hace nada si el tamaño es lo suficientemente grande. Si se ha de reservar más memoria para que el bloque sea más grande (que es el mismo caso cuando el bloque tiene tamaño cero después del constructor por defecto), la nueva porción de más se pone a cero utilizando la función de la librería estándar de C memset(), que fue presentada en el Capítulo 5. La siguiente llamada es a la función de la librería estándar de C memcpy(), que en este caso copia los octetos existentes de mem a newmem (normalmente de una manera eficaz). Finalmente, se libera la memoria antigua y se asignan a los atributos apropiados la nueva memoria y su tamaño.

La clase Mem se ha diseñado para su utilización como herramienta dentro de otras clases para simplificar su gestión de la memoria (también se podría utilizar para ocultar un sistema de gestión de memoria más avanzada proporcionado, por ejemplo, por el el sistema operativo). Esta clase se comprueba aquí con una simple clase de tipo string:

//: C07:MemTest.cpp
// Testing the Mem class
//{L} Mem
#include "Mem.h"
#include <cstring>
#include <iostream>
using namespace std;

class MyString {
  Mem* buf;
public:
  MyString();
  MyString(char* str);
  ~MyString();
  void concat(char* str);
  void print(ostream& os);
};

MyString::MyString() {  buf = 0; }

MyString::MyString(char* str) {
  buf = new Mem(strlen(str) + 1);
  strcpy((char*)buf->pointer(), str);
}

void MyString::concat(char* str) {
  if(!buf) buf = new Mem;
  strcat((char*)buf->pointer(
    buf->msize() + strlen(str) + 1), str);
}

void MyString::print(ostream& os) {
  if(!buf) return;
  os << buf->pointer() << endl;
}

MyString::~MyString() { delete buf; }

int main() {
  MyString s("My test string");
  s.print(cout);
  s.concat(" some additional stuff");
  s.print(cout);
  MyString s2;
  s2.concat("Using default constructor");
  s2.print(cout);
} ///:~

Listado 7.11. C07/MemTest.cpp


Todo lo que puede hacer con esta clase es crear un MyString, concatenar texto e imprimir a un ostream. La clase sólo contiene un puntero a un Mem, pero note la diferencia entre el constructor por defecto, que pone el puntero a cero, y el segundo constructor, que crea un Mem y copia los datos dentro del mismo. La ventaja del constructor por defecto es que puede crear, por ejemplo, un array grande de objetos MyString vacíos con pocos recursos, pues el tamaño de cada objeto es sólo un puntero y la única sobrecarga en el rendimiento del constructor por defecto es el de asignarlo a cero. El coste de un MyString sólo empieza a aumentar cuando concatena datos; en ese momento el objeto Mem se crea si no ha sido creado todavía. Sin embargo, si utiliza el constructor por defecto y nunca concatena ningún dato, la llamada al destructor todavía es segura porque cuando se llama a delete con un puntero a cero, el compilador no hace nada para no causar problemas.

Si mira los dos constructores, en principio, podría parecer que son candidatos para utilizar argumentos por defecto. Sin embargo, si elimina el constructor por defecto y escribe el constructor que queda con un argumento por defecto:

MyString(char* str = "");

todo funcionará correctamente, pero perderá la eficacia anterior pues siempre se creará el objeto Mem. Para volver a tener la misma eficacia de antes, ha de modificar el constructor:

MyString::MyString(char* str) {
  if (!*str) { // Apunta a un string vacío
    buf = 0;
    return;
  }
  buf = new Mem(strlen(str) + 1);
  strcpy((char*)buf->pointer(), str);
}

Esto significa, en efecto, que el valor por defecto es un caso que ha de tratarse separadamente de un valor que no lo es. Aunque parece algo inocente con un pequeño constructor como éste, en general esta práctica puede causar problemas. Si tiene que tratar por separado el valor por defecto en vez de tratarlo como un valor ordinario, debería ser una pista para que al final se implementen dos funciones diferentes dentro de una función: una versión para el caso normal y otra para el caso por defecto. Podría partirlo en dos cuerpos de función diferentes y dejar que el compilador elija. Esto resulta en un ligero (pero normalmente invisible) incremento de la eficacia porque el argumento extra no se pasa y por tanto el código extra debido a la condición condición no se ejecuta. Más importante es que está manteniendo el código en dos funciones separadas en vez de combinarlas en una utilizando argumentos por defecto, lo que resultará en un mantenimiento más sencillo, sobre todo si las funciones son largas.

Por otro lado, considere la clase Mem. Si mira las definiciones de los dos constructores y las dos funciones pointer(), puede ver que la utilización de argumentos por defecto en ambos casos no causará que los métodos cambien. Así, la clase podría ser fácilmente:

//: C07:Mem2.h
#ifndef MEM2_H
#define MEM2_H
typedef unsigned char byte;

class Mem {
  byte* mem;
  int size;
  void ensureMinSize(int minSize);
public:
  Mem(int sz = 0);
  ~Mem();
  int msize();
  byte* pointer(int minSize = 0);
}; 
#endif // MEM2_H ///:~

Listado 7.12. C07/Mem2.h


Note que la llamada a ensureMinSize(0) siempre será bastante eficiente.

Aunque ambos casos se basan en decisiones por motivos de eficacia, debe tener cuidado para no caer en la trampa de pensar sólo en la eficacia (siempre fascinante). Lo más importante en el diseño de una clase es la interfaz de la clase (sus miembros públicos, que son las que el programador cliente tiene a su disposición). Si se implementa una clase fácil de utilizar y reutilizar, entonces ha tenido éxito; siempre puede realizar ajustes para mejorar la eficacia en caso necesario, pero el efecto de una clase mal diseñada porque el programador está obsesionado con la eficacia puede resultar grave. Su primera preocupación debería ser que la interfaz tenga sentido para aquéllos que la utilicen y para los que lean el código. Note que en MemTest.cpp el uso de MyString no cambia independientemente de si se utiliza el constructor por defecto o si la eficacia es buena o mala.