To me, there's really one thing about C++ that makes it stand out against all the other popular industrial programming languages, when it comes to basic language features: it's taken the idea of 'user-defined types' about as far as it can go. This goes beyond simple object orientation, or even operator overloading. In particular, to permit library definitions of things like smart, reference-counted pointers (like Boost's shared_ptr) and dynamically sizable arrays and strings (such as std::basic_string and std::vector), there's some key requirements: copy constructors, assignment operator overloading, and destructors, with strong guarantees about being automatically called. They're required so that classes which aren't simply blittable can do the required bookkeeping. For example, classes might use dynamic allocation in a non-GC environment, so they need to call new / delete etc. as necessary to maintain the right ownership of data that member pointers refer to.
Now, those features, in isolation, aren't too bad. If they didn't exist, the user would
have to do all the tedious, repetitious bookkeeping themselves, and that would be error prone.
Throwing exceptions into the mix is what really causes problems. It was Herb Sutter's
Exceptional C++,
Item 10 which opened my eyes to the big problem - referring to writing a Pop()
method
on a Stack
. When writing a function that returns a value of a user-defined type
(such as a member function of a template class, or a template function), that function can't
protect against an exception being thrown in the copy constructor. If it can't protect against
an exception, then it can't be strongly exception-safe if it also modifies data, because it's not
possible to roll back when an exception is thrown in the copy constructor. All this is
one of the reasons that C++'s std::stack::pop()
doesn't return the popped value.
Unfortunately, it's not very easy to guarantee that a useful copy constructor won't throw an exception. One of the handiest uses for a copy constructor is to make a deep copy of data that the class has dynamically allocated. That means that it's probably going to call operator new, which can throw if there's not enough memory. Even if it didn't throw or the nothrow variant is used, there's no easy way to return an error code instead, so an exception is pretty much required.
How much of a limitation is that, really? I actually don't think it's a big limitation. Not many systems deal very gracefully in extremely memory-constrained situations. Probably the best way to deal with it is not to run into an exception at all, by calculating up-front how much memory is going to be needed early on in a call stack, and throwing an exception if the required amount + slack isn't available. Of course, that tactic isn't rock-solid for several reasons, including race conditions with allocation on other threads, and the difficulty and flattening of abstraction in making the required calculation.
There's more to this value orientation. The construction of new value types that are supposed to
work just as well as builtin types uses the same keywords (struct
and class
)
as the keywords to create more traditional object-oriented types (i.e. Java-style reference types),
focused on things like polymorphism and dynamic dispatch. Thing is, writing correct value types is
far harder than writing correct Java-style types, and C++ has some features that seem to make value
types the default.
It's only with value-oriented types that you need to deal with copy constructors, assignment operators and implicit and explicit type conversions. All you need to do in C++ to create a copy constructor or an implicit type conversion is have only one argument to your type's constructor, and C++ will automagically call your constructor, sometimes when you least expect it (e.g. calling a function with the wrong type, which happens to match a constructor). Hence, the requirement for an 'explicit' keyword which prevents this behaviour when it's undesireable. Similarly, C++ automatically creates an assignment operator that will copy the binary data in the class, and it permits object slicing - copying a subtype into a supertype location and lopping off everything that made the subtype a subtype, almost as if you could turn a mammal into an earlier, sea-based life form by chopping off its legs.
On the other hand, this capability permits some pretty neat functionality - the fact that
std::basic_string
or CComPtr in the ATL can be implemented without extending the
language or modifying the compiler is admirable. I just wonder if it cost too much. One thing
I'm certain of, from my prejudiced OO and functional (where appropriate) perspective: the
strong lean towards value orientation over and above Java-style reference-based object orientation
is a flaw in C++. Programming languages, above all things, should keep the principle behind the
"Pit of Success" foremost,
and always make the natural way of phrasing the solution to a problem the correct way.
Of course, the best practices that determine the natural solution adjust slowly over the years. But when did common domain objects like Person, Car and Account ever need to act like integers, freely copyable and duplicated hither and thither? I'm not sure they ever did, so I'm not inclined to let C++ off the hook just for historical reasons.
No comments:
Post a Comment