Here’s some rubbish code I wrote:
class Connection: public util::Stream { ... public: class Observer { public: virtual ~Observer() {} virtual unsigned OnHttpHeader(const std::string& key, const std::string& value) = 0; virtual unsigned OnHttpData() = 0; virtual void OnHttpDone(unsigned int error_code) = 0; }; private: explicit Connection(Observer*); friend class Client; public: ~Connection(); unsigned int OnActivity(); // Being a Stream unsigned Read(void *buffer, size_t len, size_t *pread); unsigned Write(const void *, size_t, size_t*) { return EINVAL; } }; typedef boost::intrusive_ptr<Connection> ConnectionPtr; class Client { public: Client(); /** Passing a NULL verb means POST (if !body.empty()) or GET (otherwise). */ ConnectionPtr Connect(util::PollerInterface *poller, Connection::Observer *obs, const std::string& url, const std::string& extra_headers = std::string(), const std::string& body = std::string(), const char *verb = NULL); };
It’s meant to be an
I mean, just look at it: it’s obviously completely bogus. [Short pause whilst you just look at it.]
Actually, no. It wasn’t at all obvious to me just from looking at it, that it was completely bogus. The only point, in fact, at which it became obvious that it was completely bogus, was when it started causing bugs.
And the bugs it caused were quite awkward ones: crashes (and Valgrind violations), obviously timing-related, to do somehow with Connection pointers still being used after the object had gone away — which should really have been disallowed by the smart-pointer class.
It turned out that the problem was if the transaction completed too quickly. At the time the code was written, the PollerInterface object didn’t own (smart pointers to) the pollable items hung off it. So, in order to stop the Connection object disappearing, due to the final reference going away, during a call to OnActivity, OnActivity itself creates and holds an additional reference to the Connection object during its execution. But if the transaction got started quickly enough, the first call to OnActivity would happen before the constructor returned — in other words, before the pointer had been assigned to the smart-pointer that’s the result of Connect(). So the “additional” reference held inside OnActivity would be the only reference — and when it went away at the end of the function, there’d be no outstanding references and the object would be deleted.
The effect was as if the constructor had included “delete this” — resulting in the new call in “p = new Connection” returning a pointer that was already dead and dangling before even being assigned to p.
Completely bogus. And worse, a completely bogus design; given the constraints, there was nothing that could possibly be done in the methods of the classes Client and Connection that would correctly implement that interface. The interface itself needed to change. Fortunately, I decided it didn’t need to change much:
class Connection { ... public: /** Start the HTTP transaction. * * Immediate errors (failure to parse host, failure of connect() * call) are returned here; errors happening any later come back * through Observer::OnHttpDone. In fact, OnHttpDone may be called * before Init() returns, i.e. you need to be ready for OnHttpDone * calls before you call Init(). If Init() returns a failure, * OnHttpDone has not been called, and is guaranteed not to be * called afterwards. Otherwise, it's guaranteed it WILL be * called. */ unsigned int Init(); ... };
So you get a completely inert ConnectionPtr back from Client::Connect, and only then — once you’ve safely squirreled-away the smart-pointer — do you light the blue touch-paper by calling Connection::Init().
But although this version at least makes it possible to use the functionality correctly, it doesn’t really make it easy — and that should be the real goal of any act of API design. There’s that uneasy division of labour between Connect() and Init() — methods on two different classes — and there’s a whole paragraph of complex object-lifetime issues to read and understand (or, as they’re otherwise known, bugs waiting to happen) for users of Init().
This, and particularly the lifetime of the Connection::Observer object, which usually ends up having to call “delete this” in its OnHttpDone() method, left me with one of those code itches that tells me that I (and, the largely theoretical, other users of this API) am writing more complex and icky client code than I should be.
Neatening this up required making the object-lifetime issues more
sane, which in turn involved greater use of smart pointers. (Not quite
Wheeler’s
Law, because there was already a use of indirection, and the change
involved only a strengthening from reference to ownership.) In the
next release of Chorale, the PollerInterface has been
replaced by a Scheduler, which keeps smart pointers to its
pollable items, allowing the
class Connection: public util::Stream { public: virtual ~Connection() {} /** Called with data from the returned HTTP body (if transaction * succeeds). */ virtual unsigned Write(const void *buffer, size_t len, size_t *pwrote); /** Called with each incoming HTTP header (if connection succeeds). */ virtual void OnHeader(const std::string& /*key*/, const std::string& /*value*/) {} /** Called with the overall result of the connection/transaction attempt. */ virtual void OnDone(unsigned int error_code) = 0; }; typedef util::CountedPointer<Connection> ConnectionPtr; class Client { class Task; public: Client(); /** Passing a NULL verb means POST (if body != NULL) or GET (otherwise). */ unsigned int Connect(util::Scheduler *poller, ConnectionPtr target, const std::string& url, const std::string& extra_headers = std::string(), const std::string& body = std::string(), const char *verb = NULL); };
So the Scheduler owns the (unseen to the library user) connection Task objects, and the connection Task objects own the Connection stream target objects. Connect() can return immediate errors from the socket ::connect call or from URL parsing, while deferring any other errors to come back through a later OnDone callback — all without there being any ambiguity of the lifetime of the streams or their observers.
(There would be a problem if the Connection object also had a smart pointer to the Task object, as then neither would ever be deleted and both would become memory leaks. But, because the data is pushed from the Task to the Connection, the Connection never needs to see the Task object — and indeed can’t, because Tasks live and die entirely inside library code, and users of the library can’t even obtain pointers to them.)
|
So here’s the thing. The third design is clearly better than the second. Well, it’s clearly better than the first, too, but mainly because the first doesn’t work, which is a boring and trivial way for one design to be better than another. The interesting thing is that it’s better than the second.
And better it certainly is — making this change halved the size of the code that uses the library, as well as making it more intentional and less fragile and stylised.
So why, having got to the second design, was I not satisfied? Why did I carry on thinking about it, waiting for the inspiration of the third design to strike? And why, having come up with the third design, was there a feeling of happiness that wasn’t present when writing the the second one, even when it passed all the unit tests the first one failed?
The only answer I can come up with is to theorise the existence of an almost aesthetic sense of code quality — which is worrying in a couple of ways. Firstly, because what is instinctive is rarely communicable, and what is not communicable is soon lost: a software engineer’s fate is that of the wheelwright in the old story of Duke Huan.
But worse than that: if code quality is in fact an aesthetic, and thus extrarational, experience, then it raises the prospect that others, even other good software engineers, could have a different sense of aesthetics, ultimately resulting in a point where you and they are pulling the same code in opposite directions. (I heard recently of a software organisation, believers in the currently-fashionable “agile”, “refactor-mercilessly” style of development, in which two otherwise talented engineers spent all their time rewriting each others’ code rather than pushing things forward — as their aesthetic senses, and frankly their assumptions about who was “in charge” in the deliberately un-micro-managed environment, butted heads.)
No aesthete could get away with “correcting” the second design above into the first: the failing unit tests would prevent that. But are there those who would correct the third design into the second, in the opposite direction to me? If so, why? And, even more importantly, if not, why not?