|
|
|
|
Choosing overloading vs. default arguments
Both function overloading and default
arguments provide a convenience for calling function names. However, it can seem
confusing at times to know which technique to use. For example, consider the
following tool that is designed to automatically manage blocks of
memory for you:
//: 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 ///:~
A Mem object holds a block of
bytes and makes sure that you have enough storage. The default
constructor doesn’t allocate any storage, and the second constructor
ensures that there is sz storage in the Mem object. The destructor
releases the storage, msize( ) tells you how many bytes there are
currently in the Mem object, and pointer( ) produces a
pointer to the starting address of the storage (Mem is a fairly low-level
tool). There’s an overloaded version of pointer( ) in which
client programmers can say that they want a pointer to a block of bytes that is
at least minSize large, and the member function ensures
this.
Both the constructor and the
pointer( ) member function use the private
ensureMinSize( ) member function to increase the size of the memory
block (notice that it’s not safe to hold the result of
pointer( ) if the memory is resized).
Here’s the implementation of the
class:
//: 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;
} ///:~
You can see that
ensureMinSize( ) is the only function responsible for allocating
memory, and that it is used from the second constructor and the second
overloaded form of pointer( ). Inside ensureMinSize( ),
nothing needs to be done if the size is large enough. If new storage must
be allocated in order to make the block bigger (which is also the case when the
block is of size zero after default construction), the new “extra”
portion is set to zero using the Standard C library
function memset( ), which was introduced in Chapter 5. The
subsequent function call is to the Standard C library function
memcpy( ), which in this case copies the
existing bytes from mem to newmem (typically in an efficient
fashion). Finally, the old memory is deleted and the new memory and sizes are
assigned to the appropriate members.
The Mem class is designed to be
used as a tool within other classes to simplify their memory management (it
could also be used to hide a more sophisticated memory-management system
provided, for example, by the operating system). Appropriately, it is tested
here by creating a simple “string” class:
//: 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);
} ///:~
All you can do with this class is to
create a MyString, concatenate text, and print to an
ostream. The class only contains a pointer to a
Mem, but note the distinction between the default constructor, which sets
the pointer to zero, and the second constructor, which creates a Mem and
copies data into it. The advantage of the
default constructor is that you
can create, for example, a large array of empty MyString objects very
cheaply, since the size of each object is only one pointer and the only overhead
of the default constructor is that of assigning to zero. The cost of a
MyString only begins to accrue when you concatenate data; at that point
the Mem object is created if it hasn’t been already. However, if
you use the default constructor and never concatenate any data, the destructor
call is still safe because calling delete for
zero is defined such that it does not try to release storage or otherwise cause
problems.
If you look at these two constructors it
might at first seem like this is a prime candidate for default arguments.
However, if you drop the default constructor and write the remaining constructor
with a default argument:
MyString(char* str = "");
everything will work correctly, but
you’ll lose the previous efficiency benefit since a Mem object will
always be created. To get the efficiency back, you must modify the
constructor:
MyString::MyString(char* str) {
if(!*str) { // Pointing at an empty string
buf = 0;
return;
}
buf = new Mem(strlen(str) + 1);
strcpy((char*)buf->pointer(), str);
}
This means, in effect, that the default
value becomes a flag that causes a separate piece of code to be executed than if
a non-default value is used. Although it seems innocent enough with a small
constructor like this one, in general this practice can cause problems. If you
have to look for the default rather than treating it as an ordinary
value, that should be a clue that you will end up with effectively two different
functions inside a single function body: one version for the normal case and one
for the default. You might as well split it up into two distinct function bodies
and let the compiler do the selection. This results in a slight (but usually
invisible) increase in efficiency, because the extra argument isn’t passed
and the extra code for the conditional isn’t executed. More importantly,
you are keeping the code for two separate functions in two separate
functions rather than combining them into one using default arguments, which
will result in easier maintainability, especially if the functions are
large.
On the other hand, consider the
Mem class. If you look at the definitions of the two constructors and the
two pointer( ) functions, you can see that using default arguments
in both cases will not cause the member function definitions to change at all.
Thus, the class could easily be:
//: 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 ///:~
Notice that a call to
ensureMinSize(0) will always be quite efficient.
Although in both of these cases I based
some of the decision-making process on the issue of
efficiency, you must be careful not to fall into the
trap of thinking only about efficiency (fascinating as it is). The most
important issue in class design is the interface of the class (its public
members, which are available to the client programmer). If these produce a class
that is easy to use and reuse, then you have a success; you can always tune for
efficiency if necessary but the effect of a class that is designed badly because
the programmer is over-focused on efficiency issues can be dire. Your primary
concern should be that the interface makes sense to those who use it and who
read the resulting code. Notice that in MemTest.cpp the usage of
MyString does not change regardless of whether a default constructor is
used or whether the efficiency is high or
low.
|
|
|