|
|
STL and OO Don't Easily Mix
Dietmar Kühl, Phaidros Software AG (dietmar_kuehl@yahoo.com)
Abstract
The STL is a powerful tool for many kinds of processing. Unfortunately,
using polymorphic objects with the STL seems not to work: Polymorphic
objects stored in STL containers either get sliced (i.e. only the
base part is copied or assigned but not the derived part) or, when
storing pointers to them instead, are not destroyed. Applying
algorithms to such containers often results in the wrong thing or
complex predicate objects are needed. This article shows how to
overcome at least some of these problems using some adaptors and also
outlines a possible implementation of STL for better integration with
polymorphic objects. The improved integration just acknowledges the
distinction between the object and the entity used to maintain it.
Introduction
Lets solve a rather simple problem: A set of of customers being either
companies or persons is to be read from a file and to be printed in
alphabetic order of the names. The customers are represented with
customer being an abstract base class of the two concrete
classes company and person. The class customer
declares a virtual function name() and defines a "less than"
operator for alphabetic comparison of two objects' name. In addition,
it defines input and output operators for reading and writing the
polymorphic objects. This is a model well suited to show typical
problems with polymorphic objects when using the STL, i.e. the
algorithm, containers, and iterators library from the standard C++
library.
Following the encouraging tests made on sets of ints to read,
sort, and print them, a simple program should solve the task at hand:
|
std::ifstream in("customer.txt");
std::istream_iterator<customer> beg(in), end;
std::vector<customer> vec(beg, end);
std::sort(vec.begin(), vec.end());
std::copy(vec.begin(), vec.end(),
std::ostream_iterator<customer>(std::cout, "\n"));
|
Since this example followed from the tests using int by
simply replacing each int by customer there should be
no problem. The compiler thinks differently and complains about
customer being abstract. A solutions seems to be using pointers
to customer rather than objects which immediately requires
that they are deleted after running through the loop. Also, it is no
longer possible to just rely on the sort() function to select
the right predicate for the comparison: Comparing two pointers with
the "less than" operator does something rather useless (at least
in this context) and does not at all call the lexicographic compare.
Using a bunch of function objects and with some trickery we could
get the example to work but the code is rather complex even for
such a basic problem. Taking it further and applying STL for things
not that trivial really obfuscates the original advantage of STL,
namely that it is easy to apply to arbitrary data structures: It is
not at all easy to apply to polymorphic objects! Investigating what
causes these problems helps in creating solutions to them, fostering
integration of object orientation with STL.
Values and References
If the above example would not have used an abstract base as the
value type for the container, the compiler would have accepted the
code without complaint. Unfortunately, this is not the solution
to the problem because the result would do the wrong thing: STL
is oriented towards values and not towards references resulting in
polymorphic objects being "sliced". That is, their dynamic type is
not retained and only the base part of the objects is copied which is
clearly not the right thing when polymorphic objects are to be stored.
Dealing with polymorphic objects always requires some form of indirection
which is actually the only way to access objects in typical
object-oriented programming languages with C++ being an exception. In C++
this means that a container of polymorphic objects would store some
form of a pointer. Using plain pointers is probably not the right
thing, at least not if the STL containers are used:
-
Objects stored in the container have to be released somehow.
Since the STL containers only destroy the value, i.e. the pointer,
some entity unrelated to the container has to be in charge of
the objects in the container. A possible solution to the lifetime
management can be reference counted pointers.
-
When using STL with values these are copied creating different
instances of the objects. When pointers are used, copying
creates a new pointer which refers to the same object as the
original. Whether this is the right thing somewhat depends on
the specific use but STL idioms somewhat expect that real copies
are made. Thus aliasing problems are rather likely: For example,
just copying a container prior to modifying the elements does
not suffice to retain the original state.
If aliasing problems are no concern, a reference counted pointer
might be the right approach. Otherwise, a copying pointer can be used
which creates copies of the referenced object with the correct dynamic
type. This can even be done without any additional requirement beyond
the objects being copy constructible: A copying pointer holds a
pointer to an object derived from an auxiliary base capable of
cloning the value. The only requirement is on the style objects of the
copying pointer type are created: The constructor takes a reference to
the concrete object type which is used to initialize a heap allocated
object of a class derived from the auxiliary base.
This copying pointer implementation consists of three template
classes:
-
The copying pointer class poly<T> which is templatized
with some base class.
-
An auxiliary abstract base class poly_base<T> which is
templatized with the same T as poly<T>: Each
poly<T> holds a pointer to a poly_base<T>.
-
An auxiliary poly_object<T, S> class which is derived from
poly<T>. The additional template argument S is a
class derived from T.
The basic idea is that a templatized constructor of poly<T>
allocates an object of type poly_object<T, S> to which
a pointer is stored in the poly<T> object. On copying or
assigning the copying pointer poly<T> the concrete object is
copied. The implementation of this is pretty simple. Here is the
auxiliary base class declaring the abstract methods used to clone
and access objects:
|
template <typename T>
struct poly_base {
virtual ~poly_base() {} // support derivation
virtual poly_base* clone() = 0; // copy derived object
virtual T& get_object() = 0; // stored in derived
};
|
The base class defines a virtual destructor to allow deletion of
derived objects using a pointer to a base object. In addition, it
declares an abstract function clone() which allocates a
copy of the object and an abstract function get_object() used
to obtain a reference to the T object hold in the derived
object. The derived class just implements these methods:
|
template <typename T, typename S>
struct poly_object: public poly_base<T> {
poly_object(S const& s): object(s) {}
poly_base<T>* clone() { return new poly_object(*this); }
T& get_object() { return object; }
private:
S object;
};
|
Each poly_object<T, S> stores an object of type S to
which a reference is returned by the get_object() method, i.e.
S is derived from T. The stored object is initialized
using copy construction. The whole purpose of this class system is
the method clone() which creates a copy of the correct type
without requiring such a method for the type T: This method
just allocates a new copy of poly_object<T, S> initialized
by copy construction from *this.
These two classes are just auxiliary classes which are not used
directly by the user. Instead, the user only uses the copying
pointer:
|
template <typename T>
struct poly {
poly(): ptr(0) {} // no object by default
template <typename S> // creation from prototype
poly(S const& s):
ptr(new poly_object<T, S>(s)) {}
poly(poly const& p): // create a cloned object
ptr(p.ptr->clone()) {}
~poly() { delete ptr; } // release current object
poly& operator= (poly const& p) { // assign cloned object
poly_base<T>* tmp = p.ptr->clone();
std::swap(ptr, tmp);
delete tmp;
return *this;
}
// Accessors to the hold object:
T& operator*() const { return ptr->get_object(); }
T* operator->() const { return &(ptr->get_object()); }
private:
poly_base<T>* ptr;
};
|
Each poly<T> object is just a reference to an actual
polymorphic object. On copying or assigning a poly<T> object,
a new copy of the hold objects is created with the correct dynamic
type. Such objects can be put into an STL container without any
possibility of creating aliasing conflicts. The tricky part is the
templatized constructor: This constructor takes an object used as
prototype to construct the internal object. It is expected that
the static type of this object matches the dynamic type. If this is
not the case, the copied object will be sliced because the type is
inferred from the static type.
A simple use of the copying pointer could look like this:
|
std::vector<poly<customer> > customers;
customers.push_back(company("Foo Inc."));
customers.push_back(person("Sam Sample"));
|
This would create a std::vector with two customers.
Of course, this is just one of many approaches to put
polymorphic objects into a container and having them maintained
automatically. This approach will be used in the remainder of the
article as an example but the following discussion applies to other
forms of smart pointers, too.
Running Algorithms
Now that std::vector<poly<customer> > can be used to hold
the polymorphic objects, the next thing to be done is sorting and
printing them. Just using
|
std::sort(vec.begin(), vec.end());
|
does not work because there is no less than operator defined for
poly<T>. Similarly when trying to send the objects to the
output there is not output operator. However, there is a rather
simple approach to solve this: The corresponding operations can
simply be implemented for poly<T> forwarding the requests to
the referenced object:
|
template <typename T>
bool operator< (poly<T> const& p1, poly<T> const& p2) {
return *p1 < *p2;
}
template <typename T, typename cT, typename traits>
std::basic_ostream<cT, traits>&
operator<< (std::basic_ostream<cT, traits>& out,
poly<T> const& p) {
return out << *p;
}
|
For the limited number of operators defined this is a viable approach
giving a useful interface, too: The only change necessary to make the
original code work is to use poly<customer> instead of just
customer.
However, this does not work well with function
objects operating on the base class: For example, if there would
be a compare function for customer objects rather than an
operator, this would not really work. To use function objects with
the container holding polymorphic objects, it is necessary to create
an adaptor. Actually, the standard provides a set of adaptors for
this: Giving a pointer to member function to the function
std::mem_fun() returns a functor which can be applied to
pointers. Unfortunately, this approach has two problem:
-
It obviously only works with member functions and the standard
library does not support a corresponding approach for non-member
functions (std::ptr_fun() does not use pointers).
-
It only works with raw pointers but not with smart pointers.
Unfortunately, the operator()() of the adaptor class cannot
simply be templatized on the argument types because there is a
typedef for the argument types.
Basically, what is missing to use some function object with iterators
on a collection of smart pointers is additional dereferencing: The
iterator has to be dereferenced once to get at the smart pointer
and then once again to get at the held object. Below is a generic
adaptor adding the necessary dereference to function objects. The
implementation of the adaptor needs a few auxiliary classes which
are used to deal with qualifier on the types:
|
template <typename T, template <typename S> class Ptr>
struct ptr_traits {
typedef Ptr<T> const type;
};
template <typename T, template <typename S> class Ptr>
struct ptr_traits<T const&, Ptr> {
typedef Ptr<T> const type;
};
template <typename T, template <typename S> class Ptr>
struct ptr_traits<T&, Ptr> {
typedef Ptr<T> type;
};
|
The template class ptr_traits is used to define a smart pointer
type appropriate for being passed as argument to a function. The
definition deals appropriately with type qualifiers: If the template
argument is a reference, the reference part is removed from the
type. In addition, the reference part together with a const
qualifier is used to determine whether the smart pointer can be passed
as const reference or has to be passed as non-const
reference. The first template argument is the type referenced by
the smart pointer and the second template argument is a template
class defining the smart pointer type.
The argument types in the adaptor class are determined from the
corresponding typedefs of the function object being adapted by
just using the type defined by ptr_traits applied to these
types with the smart pointer template as second argument. To be a
well behaved functor, the adaptor also provides the corresponding
typedefs. The function call operator just calls the underlying
function object after dereferencing the arguments:
|
template <template <typename T> class Ptr, typename Fct>
struct binary_deref_function
{
// typedefs required to be a binary function object:
typedef typename
ptr_traits<typename Fct::first_argument_type, Ptr>::type
first_argument_type;
typedef typename
ptr_traits<typename Fct::second_argument_type, Ptr>::type
second_argument_type;
typedef typename Fct::result_type result_type;
// constructor:
binary_deref_function(Fct const& f = Fct()): fct(f) {}
// function call operators:
result_type operator()(first_argument_type& arg1,
second_argument_type& arg2)
{ return fct(*arg1, *arg2); }
result_type operator()(first_argument_type& arg1,
second_argument_type& arg2) const
{ return fct(*arg1, *arg2); }
private:
Fct fct;
};
|
The first template argument to this class is a template used
to define the types of the arguments: This template argument
is passed to the ptr_traits to obtain the argument types
of the functor. Thus, the first template argument should name a smart
pointer template class. The second template argument is the functor
which is adapted for use with smart pointers.
To simplify creation of the function adaptor, a few functions
creating objects of this type can be provided similar to the
function std::mem_fun. For example:
|
template <template <typename T> class Ptr,
typename Result, typename Arg1, typename Arg2>
binary_deref_function<Ptr,
std::pointer_to_binary_function<Arg1, Arg2, Result> >
binary_deref_fct(Result (*fct)(Arg1, Arg2)) {
return std::ptr_fun(fct);
}
|
The function provides a function object dereferencing its
arguments prior to calling the binary function passed as
argument. This can be used to sort a container customers
with poly<customer> objects like this:
|
std::sort(customers.begin(), customers.end(),
binary_deref_fct<poly>(compare));
|
For function objects returning types different from the elements in
the sequence this approach works fairly well. For function objects
returning the elements of the sequence as might be desirable e.g. for
std::transform(), the above adaptor would not return the
smart pointer to the object but rather the object itself. This
problem can be avoided using another adaptor which appropriately
changes the return type, too. Thus it would be desirable to have
even better integration of smart pointers.
Three, Not Two, Involved Types
Using [smart] pointers in containers introduces the problem of
having two objects representing one element: There is the pointer
and the object pointed to. For operations adding elements to a
container or to move elements within a container, the pointer has
to be used to avoid slicing of objects. For predicates and functors
the referenced object is used. The pointer which ends up as being the
value type of the container is of no interest while the referenced
objects are. That is, there are two roles involved in the operations:
The role of the pointer which just holds an object and the role of
the held object which is used with predicates, functors, etc.
For homogeneous containers with elements of a concrete type there is
no such distinction: The value type is used when adding or moving
elements as well as for predicates and functors. That is, the value
type appears in both roles, being holder and held object at once
. To achieve better
integration of pointers, algorithms should be able to distinguish
between these two roles using the holder role when objects are moved,
copied, etc. and using the held object role when accessing objects,
applying predicates or functors, etc. The iterators passed to the
algorithm would be used to access either the holder or the held object
depending on what kind of operation is to be done. For example, let
poly_vector be a template class used to maintain polymorphic
objects e.g. by using the template class poly internally to
maintain the objects:
|
// create a sample container:
poly_vector<customer> vec;
vec.push_back(person("Sam Sample"));
vec.push_back(company("Foo Inc."));
// use the holder role:
std::reverse(vec.begin(), vec.end());
// use the held object role:
std::find(vec.begin(), vec.end(), person("Sam Sample"));
|
To reverse the elements in the sequence, the holder objects have
to be exchanged: Using the held object type to swap the elements
would slice the elements in the above case. This does not occur if
the holders are swapped. On the other hand, finding a match for the
newly created object passed as third argument to std::find()
has to inspect the held objects.
The idea is to use the held object as value type when using access
functions like operator*() on iterators even if the
holder and the held object are different entities. However, the
holder object is used when objects are moved or added, e.g. when
std::reverse() swaps two elements. Of course, in cases where
there is no distinction between the holder and the held object
this does not make a difference. If there is a distinction between
holder and held object as is the case when smart pointers are used,
this allows moving the explicit dereferencing applied e.g. in
the binary_deref_function functor into the container and
iterator removing the need to explicitly deal with pointers when
using inhomogeneous containers. On the other hand, some burden is
put on the implementation of algorithms which potentially have to
distinguish between the two roles.
An approach to the implementation of algorithms taking care of the
two different roles is the use of functions from a traits class doing
the appropriate operations: The default implementation just performs
the operation on the value type while a specialized traits class
can perform the operation using the holder objects. For example,
the std::reverse() function could be implemented like this:
|
template <typename It>
void reverse(It begin, It end) {
for (; begin != end && begin != --end; ++begin)
holder_traits<It, It>::swap(begin, end);
}
|
The class holder_traits collects mutating functions which
can be specialized to operate on the holder type. Two iterators are
used as template arguments because to operate on the holder objects
these objects have to be accessible from both iterators. Since the
iterator types may differ, e.g. when swapping elements between
collections of different type, the specializations can depend on
two iterator types. The default implementation just uses the value
type. For example, the default implementation of swap()
would just swap the elements using
std::swap():
|
template <typename It1, typename It2>
void holder_traits<It1, It2>::swap(It1 it1, It2 it2) {
std::swap(*it1, *it2);
}
|
Specializations of this swap() member would swap the holders
pointed to by the iterator. That is, specializations would not
dereference the iterator but rather use the data internal to the
iterator to get access to the holder rather than to the held object.
Some implementations partially support this approach already
in the form of using std::iter_swap() rather than
std::swap() in some STL algorithms. Clearly,
iter_swap() can be implemented in terms of the above traits
class.
In addition to a method swap() for exchanging the contents
of two elements, an assign() method would be available in the
holder_traits to change the value: When assigning polymorphic
values, it is necessary to change the type of the changed object if
types do not match. This method would also be used when adding new
elements using an inserting iterator. Other forms of adding elements
are up the containers anyway, i.e. a container for polymorphic
objects can do the right thing already when adding new elements.
If STL algorithms would be required to distinguish between the holder
and the held object, use of collections with polymorphic objects
could much more convenient. In addition to mere convenience, this
can be used to change the handling of objects in a container without
changing how the container is used. This can be important if moving
objects turns out to be too expensive and instead of moving them,
the container type is changed to one where only pointers are moved
rather than the objects.
Conclusions
Although STL algorithms and containers don't seem to mix at all with
polymorphic objects, it is actually not that bad. With a few adaptors
and a little bit of trickery, typical tasks can be made reasonably
easy. The trickery can be written by some advanced C++ users with
the other just using them as tools. However, for full integration of
polymorphic objects with the STL algorithms, additional guarantees
about the implementation of the algorithms would be desirable. The
major drawback of such guarantees would be additional restrictions on
the implementations of algorithms making them marginally more complex.
References
-
Matthew H.Austern.
Generic Programming and the STL.
Addison-Wesley, Reading. 1998.
-
Nicolai M.Josuttis.
The C++ Standard Library.
Addison-Wesley, Reading, 1999.
|