C++ Tips: 2 - Avoid designing undefined behaviour

| 0 Comments
When designing code it's often easy to include undefined behaviour. The need for code that exhibits this kind of behaviour is, however, generally pretty rare and there are often ways around allowing undefined behaviour. In general it's usually best to try to avoid undefined behaviour and instead be clear about exactly what happens under all usage conditions.

Undefined behaviour can be part of your contract with the client, you expect them to adhere to their side of the contract and if they do then you will keep your side; if they don't then all bets are off. A common example is where you have clients who will trade improved performance for less safety and checking. Your code assumes that they know what they're doing and offers no guarantees as to what happens if they don't. For example, it may be possible for the client to determine the number of elements in a collection and do their own bounds checking so that they never ask for out of bound data, if your only data access method always provides bounds checking then you are extracting a performance penalty from those who always access you with valid parameters. Skipping the bounds checking makes your code fractionally faster (which may make all the difference when used in a tight loop) but introduces risk because you are assuming that input data is valid when it may not be. In general if you really think that you need to provide a method that can exhibit undefined behaviour under certain error conditions then it's probably best to also include a version that behaves in a controlled way under the same error conditions. For an example see std::basic_string and its at() and operator[] methods which both provide indexed access to the data. at() checks the supplied index and throws an out_of_range exception on an invalid index and operator[] need not perform any index checking and exhibits undefined behaviour when supplied an invalid index. See "Contract Programming 101" by Matthew Wilson for more details.

The thing about undefined behaviour is that, by definition, it can be anything. This makes it difficult to write unit tests for. Sure, it's possible to have the 'undefinedness' be something that integrates with your testing framework in debug builds but it's unlikely that you'd want it to do this in your release builds; especially if the purpose of the undefined behaviour is to improve performance by not checking things and trusting the caller. So, functions that include undefined behaviour in their contracts are hard to unit test under failure conditions that give rise to the undefined behaviour. You can write a test that exercises the code with a contractually correct usage pattern but you can't prove that the code fails when and how it should because you can't know what such a failure will do.

Obviously there may be other reasons that you either cannot or would prefer not to fail in a defined manner but they're generally few and far between (unless, perhaps, you're working on high performance generic container implementations). By all means think about providing an unchecked, undefined on failure, version of a function but don't bother implementing it or using it until your profiling has shown that the code that you think requires it actually does. With fine grained unit tests you should be able to determine the actual performance requirements reasonably quickly by unit testing the user of the code under the kind of situations that you expect performance to be critical. If you attempt to avoid undefined behaviour for as long as possible then you may often find that you don't actually really need it.

I know some people will say that there's nothing really to worry about because they can include an assert to validate that the caller is sticking to the contract. This is true but if the checking code remains in all builds of the code then the code need no longer exhibit undefined behaviour and if the checking code is not included in some builds then you may find yourself looking for heisenbugs since the code that runs in production may not be the same code that you can debug and test.

By definition, undefined behaviour is irrecoverable. Since there's no specification of what the behaviour is there's nothing you can do when it happens. If you're writing code that needs to be robust and needs to fail in a graceful and controlled manner than as soon as you step into undefined behaviour you cannot guarantee that the code is robust and you are incapable of failing gracefully under all situations.

Of course some people will claim that using functions which include undefined behaviour in their specifications is OK as long as the caller plays by the rules; this is true, to a point. The problem is that there's very little that you can do to prove that the caller is always going to play by the rules. Checks that compile out of release code may give you a warm fuzzy feeling when running tests in debug mode but they won't necessarily stop the software from failing in production. This is even more likely if the code in question uses multiple threads as thread scheduling is often the most affected by differences between debug and release builds.

What does all this mean in practice? You can usually avoid designing undefined behaviour if you always validate your input parameters and always document exactly how you will fail if they are invalid. This documentation may take the form of a unit test for the code in question, if it does then you can be sure that the documentation stays in sync with the code; the test fails if the documentation gets out of sync with reality.

In general I've found it far better to avoid undefined behaviour as much as possible. If you use unit testing then you may find that including undefined behaviour for certain failure conditions makes it impossible to write tests for the code or that the code that you can test is not the code that you can release. As soon as one code path includes a potential trip into undefined behaviour you may be unable to guarantee how the code will behave under certain situations, it can be considerably more difficult to deliver robust software when this is the case.

Leave a comment