Let’s say I wanted to create a Java List<String>
(see spec) implementation that uses a complex subsystem, such as a database or file system, for its store so that it acts as a persistent collection rather than an in-memory one.
So here’s a skeleton implementation:
class DbBackedList implements List<String> {
private DbBackedList() {}
/** Returns a list, possibly non-empty */
public static getList() { return new DbBackedList(); }
public String get(int index) {
return Db.getTable().getRow(i).asString(); // may throw DbExceptions!
}
// add(String), add(int, String), etc. ...
}
My problem lies with the fact that the underlying DB API may encounter connection errors that are not specified in the List interface that it should throw.
My problem is whether this violates Liskov’s Substitution Principle (LSP).
In his paper on LSP, Bob Martin actually gives an example of a PersistentSet that violates LSP. The difference is that his newly-specified Exception
there is determined by the inserted value and so is strengthening the precondition. In my case the connection/read error is unpredictable and due to external factors and so is not technically a new precondition, merely an error of circumstance, perhaps like OutOfMemoryError which can occur even when unspecified.
In normal circumstances, the new Error/Exception might never be thrown. (The caller could catch if it is aware of the possibility, just as a memory-restricted Java program might specifically catch OOME.)
Is this therefore a valid argument for throwing an extra error and can I still claim to be a valid java.util.List
(or pick your SDK/language/collection in general) and not in violation of LSP?
Edit: This argument might be more palatable if you consider a FileBackedList
(more reliable “connection”) rather than a DbBackedList
.
If this does indeed violate LSP and thus not practically usable, I have provided two less-palatable alternative solutions as answers that you can comment on, see below.
Footnote: Use Cases
In the simplest case, the goal is to provide a familiar interface for cases when (say) a database is just being used as a persistent list, and allow regular List operations such as search, subList and iteration.
Another, more adventurous, use-case is as a slot-in replacement for libraries that work with basic Lists, e.g if we have a third-party task queue that usually works with a plain List:
new TaskWorkQueue(new ArrayList<String>()).start()
which is susceptible to losing all it’s queue in event of a crash, if we just replace this with:
new TaskWorkQueue(new DbBackedList()).start()
we get a instant persistence and the ability to share the tasks amongst more than one machine.
In either case, we could either handle connection/read exceptions that are thrown, perhaps retrying the connection/read first, or allow them to throw and crash the program (e.g. if we can’t change the TaskWorkQueue code).
9
The reason why his example is a violation of LSP is not because of the exception per se, it is the reason for the exception — it is changing the contract.
A simpler, but more contrived example — you have a List of ints, you decide you want to use it for a list of elementary grades completed, with a check to make sure that the numbers are within the required range, so you subclass ListInt as ListIntElementary. ListIntElementary violates LSP.
You, on the other hand, have real world constraints that wouldn’t apply to the base class, but your subclass can algorithmically be used anywhere the base class can, it accepts all acceptable inputs, returns only acceptable values. Exceptions are neither input nor output in the LSP sense.
A new exception or new cause for an old exception (if you can find one that maps cleanly) is an implementation detail, not a violation of LSP. It may mean that in practice that it is unsuitable as a replacement for the base class, but in theory once created, it can be used everywhere that the base class can be used.
In short, this is fine.
2
To start with, your DBException
hardly qualifies as Error:
subclasses of Error… are abnormal conditions that should never occur.
Note also that if you expect DBException
to be thrown in overridden get
, it must be unchecked one. Otherwise, your code won’t compile, per JLS 8.4.8.3. Requirements in Overriding and Hiding:
A method that overrides or hides another method, including methods that implement abstract methods defined in interfaces, may not be declared to throw more checked exceptions than the overridden or hidden method…
With above said, it looks like yes, it would violate LSP – because as a user of Java Collections Framework, I would not expect List.get
to throw runtime exception for any kind problem other than “programming mistakes”, that is for something that is possible to avoid by changing the code (note, no matter how you change code, database connection won’t be guaranteed).
If you look at the IndexOutOfBoundsException
specified for List.get, it reads like one that programmer can avoid by preliminary bounds check:
if the index is out of range (index < 0 || index >= size())
Another runtime exception documented for Collections, including List, is ConcurrentModificationException and per API docs, it is also expected to be dealt with by correcting the code that caused it:
ConcurrentModificationException should be used only to detect bugs.
More detailed explanation for what I expect is provided in JCF Design FAQ. It addresses UnsupportedOperationException
, but if you take a look at prior examples of IOOBE and CME, the reasoning fits these as well:
Won’t programmers have to surround any code that calls optional operations with a try-catch clause in case they throw an UnsupportedOperationException?
It was never our intention that programs should catch these exceptions: that’s why they’re unchecked (runtime) exceptions. They should only arise as a result of programming errors, in which case, your program will halt due to the uncaught exception.
Suming up, if I wanted to expose database backed data as List (or any Collection for that matter) in a way that would be least confusing for users of my API, I would probably wrap that data into some helper object that would expose DB related exceptions only when used “outside” of collection context – that is, when client code would try to access the data expected to be stored “inside” the wrapper, not when collections of wrappers are carried over the client code.
For a concrete example how this could be done, take a look at java.util.concurrent.Future which wraps results of an asynchronous computation in a way that doesn’t expose “internal”, wrapped exceptions when carried over in collections.
4
The LSP basically says the answer to “Will code that correctly uses this interface do the wrong thing if used with your implementation of it?” should be no. Throwing new and different exceptions may cause code that thought it was handling all exceptions to fail when a new and unexpected exceptions show up. If it is possible to map exceptions to ones already provided by the interface then that is a possible path to go down, but they have to map cleanly in the intent they are trying to communicate though. You don’t want to map to a fatal error from a nonfatal error and vice versa.
If LSP is unquestionably being violated, I could instead introduced a Option Type class that the List nominally stores and returns, which encapsulates the return types and any errors in retrieval.
/**
* Wraps a String for writing or reading from a database.
*/
interface DbString {
public DbString(String str) { /* ... */ }
/** @returns the String, or "" if hasError() returns true */
public String() getString();
/** @returns true if there was an error in retrieving the string */
public boolean hasError();
}
/**
* A list of DbStrings
*/
class DbBackedList implements List<DbString> {
// .. as before
public DbString get(int index) {
return Db.getConnection.getTable().getRow(i).asString(); // may throw!
}
// add(DbString), add(int, DbString), etc. ...
}
Usage is similar to regular lists.
List<String> l = new DbBackedList();
l.add(new DbString("foo"));
assertFalse(a.get(0).hasError());
assertEquals("foo", a.get(0).getString());
At this point, though, it’s arguably easier to create a new interface from scratch and not retrofit an existing one, however we lose some of the apparent familiarity with the List interface. If we want to provide other collections, such as Set or Map we will then have to create new interfaces for each of them.
2