As your examples already show, each such case must be evaluated separately and there is a considerable spectrum of gray between "exceptional circumstances" and "flow control", especially if your method is intended to be reusable, and might be used in quite different patterns than it was originally designed for. Do not expect us all here to agree on what "non-exceptional" means, especially if you immediately discuss the possibility of using "exceptions" to implement that.
We might also not agree on what design makes the code easiest to read and maintain, but I will assume that the library designer has a clear personal vision of that and only needs to balance it against the other considerations involved.
Follow your gut feelings except when you are designing pretty fast methods and expect a possibility of unforeseen reuse.
Each future caller may freely translate between error codes and exceptions as it wishes in both directions; this makes the two design approaches almost equivalent except for performance, debugger-friendliness and some restricted interoperability contexts. This usually boils down to performance, so let's focus on that.
As a rule of thumb, expect that throwing an exception is 200x slower than a regular return (in reality, there is a significant variance in that).
As another rule of thumb, throwing an exception may often allow much cleaner code compared with the crudest of magic values, because you are not relying on the programmer translating the error code into another error code, as it travels through multiple layers of client code towards a point where there is enough context for handling it in a consistent and adequate way. (Special case:
null tends to fare better here than other magic values because of its tendency to automatically translating itself to a
NullReferenceException in case of some, but not all types of defects; usually, but not always, quite close to the source of the defect.)
So what's the lesson?
For a function that's called just a few times during the lifetime of an application (like the app initialization), use anything that gives you cleaner, easier to understand code. Performance cannot be a concern.
For a throw-away function, use anything that gives you cleaner code. Then do some profiling (if needed at all) and change exceptions to return codes if they are among the suspected top bottlenecks based on measurements or overall program structure.
For an expensive reusable function, use anything that gives you cleaner code. If you basically always have to undergo a network roundtrip or parse an on-disk XML file, the overhead of throwing an exception is probably negligible. It is more important to not lose details of any failure, not even accidentally, than to return from a "non-exceptional failure" extra fast.
A lean reusable function requires more thought. By employing exceptions, you are forcing something like a 100 times slow down on callers who will see the exception on half of their (many) calls, if the body of the function executes very fast. Exceptions are still a design option, but you will have to provide a low overhead alternative for callers who can not afford this. Let's look at an example.
You list a great example of
Dictionary<,>.Item, which, loosely speaking, changed from returning
null values to throwing
KeyNotFoundException between .NET 1.1 and .NET 2.0 (only if you are willing to consider
Hashtable.Item to be its practical non-generic forerunner). The reason of this "change" is not without interest here. Performance optimization of value types (no more boxing) made the original magic value (
null) a non-option;
out parameters would just bring a small part of the performance cost back. This latter performance consideration is completely negligible in comparison with the overhead of throwing a
KeyNotFoundException, but the exception design is still superior here. Why?
- ref/out parameters incur their costs every time, not just in the "failure" case
- Anyone who cares can call
Contains before any call to the indexer, and this pattern reads completely naturally. If a developer wants to but forgets to call
Contains, no performance issues can creep in;
KeyNotFoundException is loud enough to be noticed and fixed.