Loading content...
In the previous page, we established that semantic compatibility—honoring behavioral contracts—is what distinguishes truly substitutable subclasses from those that merely compile. But this raises a critical question: what exactly are these 'expectations' that subclasses must preserve?
Expectations aren't always written down. They live in the minds of developers who use your classes. They're implied by method names, suggested by documentation, and established by precedent in the codebase. Some expectations are obvious; others are subtle to the point of being invisible.
Understanding and preserving expected behavior is the practical skill at the heart of LSP compliance. It requires empathy for code consumers, careful analysis of parent class contracts, and a disciplined approach to subclass implementation.
By the end of this page, you will understand how to identify expected behavior in parent classes, the specific categories of expectations that must be preserved, and practical techniques for ensuring your subclasses maintain the behavioral contracts their clients depend upon.
Behavioral expectations are established through multiple channels, some explicit and some implicit. Understanding where expectations come from helps you identify which behaviors your subclass must preserve.
deposit(amount) creates an expectation that money will be added to an account. A method named sort() is expected to sort, not shuffle. Names carry implicit contracts.int getCount() implies a non-negative integer for counting purposes. Returning -1 as an error code violates implicit expectations established by the method's purpose.save() method commits to a database synchronously, callers expect that behavior. An async subclass implementation could break code that depends on synchronous completion.equals(), hashCode(), and compareTo() have well-established contracts from language specifications. Violating these contracts causes chaos in collections and algorithms.prepare() before execute(), that ordering becomes an implicit expectation that subclasses must respect.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// EXPECTATION SOURCE: Method Nameclass Stack<T> { // The name "push" creates an expectation: // 1. Item will be added to the stack // 2. Item will be retrievable via pop() // 3. Size will increase by 1 public void push(T item) { /* ... */ } // A subclass that "pushes" to the bottom would violate expectations // even if it technically adds the item somewhere} // EXPECTATION SOURCE: Documentation/** * Validates user input according to business rules. * * @param input The user-provided input string * @return true if input is valid, false otherwise * @throws NullPointerException if input is null * * NOTE: The documentation establishes these expectations: * - Returns boolean (not throws exception for invalid) * - Only throws NPE (not other exceptions) * - "Valid" means "according to business rules" (implementation-specific) */abstract class InputValidator { public abstract boolean validate(String input);} // EXPECTATION SOURCE: Existing Implementation Behaviorclass FileWriter { public void write(String data) { // Existing behavior: writes synchronously, data is committed before return file.write(data); file.flush(); // Clients depend on: data is safe after write() returns }} class AsyncFileWriter extends FileWriter { @Override public void write(String data) { // VIOLATION: Returns before data is committed executor.submit(() -> { file.write(data); file.flush(); }); // Client code that checks file contents after write() will fail }}You cannot excuse semantic violations by saying 'it wasn't documented.' If clients reasonably expect certain behavior based on method names, return types, or the way the class is typically used, that behavior is part of the contract—documented or not.
Behavioral expectations fall into distinct categories. Understanding these categories provides a systematic framework for analyzing what your subclass must preserve.
Outcome expectations concern what a method accomplishes—the result or effect it produces.
Examples:
sort(array) → array elements are in ascending orderwithdraw(100) → account balance decreases by 100compress(data) → returned data is smaller (usually) and decompressiblePreservation requirement: Subclass methods must produce outcomes equivalent to or consistent with parent outcomes. A subclass sort() that shuffles instead of sorting violates outcome expectations.
1234567891011121314151617181920212223242526
// Parent outcome: elements are sortedclass ArraySorter { public int[] sort(int[] array) { Arrays.sort(array); return array; // Outcome: array is sorted ascending }} // VALID: Same outcome, different algorithmclass QuickSorter extends ArraySorter { @Override public int[] sort(int[] array) { quickSort(array, 0, array.length - 1); return array; // Outcome: array is sorted ascending ✓ }} // INVALID: Different outcomeclass ReverseSorter extends ArraySorter { @Override public int[] sort(int[] array) { Arrays.sort(array); reverse(array); return array; // Outcome: array is sorted DESCENDING ✗ }}When preserving expected behavior, it's important to understand that preservation doesn't always mean identical behavior. Subclasses are allowed—even expected—to enhance or specialize parent behavior. The key is that variations must be compatible with expectations.
Behavior falls on a spectrum from identical to incompatible:
| Level | Description | LSP Status | Example |
|---|---|---|---|
| Identical | Subclass behavior is exactly the same as parent | ✓ Always valid | ArrayList and LinkedList produce same results for get() on same data |
| Enhanced | Subclass does everything parent does, plus more | ✓ Usually valid | Subclass logs all operations before performing them |
| Specialized | Subclass narrows cases but handles them identically | ⚠️ Sometimes valid | Subclass only accepts positive integers but handles them correctly |
| Different but Compatible | Subclass behaves differently in ways clients don't observe | ✓ Valid if truly unobservable | Internal implementation differs but external contract honored |
| Incompatible | Subclass behavior differs in ways clients depend on | ✗ Violation | Return value meaning changed, exceptions changed, invariants broken |
The critical question is always: Will any client code that works with the parent class break or behave incorrectly when given the subclass?
If the answer is yes—even in edge cases—you have an incompatible change.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// ENHANCED (Valid): Adds behavior without breaking existing expectationsclass LoggingList<T> extends ArrayList<T> { private Logger logger; @Override public boolean add(T element) { logger.info("Adding element: " + element); // Enhancement return super.add(element); // Original behavior preserved }} // SPECIALIZED (Often Valid): Handles subset of casesclass PositiveIntegerStack extends Stack<Integer> { @Override public void push(Integer item) { if (item < 0) { throw new IllegalArgumentException("Only positive integers"); } super.push(item); // Valid case handled identically } // Note: This strengthens preconditions—technically a violation // but may be acceptable if documented and intentional} // DIFFERENT BUT COMPATIBLE (Valid): Observable behavior sameclass CachingDatabase extends Database { private Map<String, Object> cache = new HashMap<>(); @Override public Object read(String key) { // Implementation completely different (uses cache) // But clients observe same behavior: read returns stored value if (cache.containsKey(key)) { return cache.get(key); } Object value = super.read(key); cache.put(key, value); return value; // Same observable behavior }} // INCOMPATIBLE (Violation): Observable behavior differsclass PermissiveValidator extends StrictValidator { @Override public boolean validate(String input) { // Parent rejects empty strings // Child accepts them if (input.isEmpty()) { return true; // INCOMPATIBLE: parent would return false } return super.validate(input); } // Clients depending on empty string rejection will break}Before implementing a subclass, you need to understand what behavior clients expect from the parent class. Here's a systematic approach to identifying these expectations:
save(), validate(), sort()? These implicit expectations are part of the contract.Comparable, Closeable, or other standard interfaces, those interfaces have documented contracts.Put yourself in the shoes of someone using the parent class without knowing about your subclass. What would they expect? What would surprise them? What would break their code? This empathetic perspective is the key to identifying expectations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// STEP 1-2: Read documentation and study implementation/** * Repository for User entities. * * All methods throw EntityNotFoundException if entity doesn't exist. * All write operations are transactional. */abstract class UserRepository { // From this documentation, we identify expectations: // - get/find operations throw exceptions for missing entities // - write operations (save, update, delete) are atomic public abstract User findById(Long id); // throws EntityNotFound public abstract void save(User user); // transactional} // STEP 3: Examine client codevoid processUser(UserRepository repo, Long userId) { try { User user = repo.findById(userId); // Client EXPECTS exception if not found // Client does NOT check for null user.process(); } catch (EntityNotFoundException e) { // Client handles exception }} // STEP 4: Analyze test cases@Testvoid findById_withNonexistentId_throwsException() { assertThrows(EntityNotFoundException.class, () -> { repo.findById(999L); }); // This test documents the expectation: throw, don't return null} // STEP 5-6: Method name and interface expectationsclass CachingUserRepository extends UserRepository { // From all sources, we've identified expectations: // 1. findById throws (not returns null) for missing // 2. save is transactional // 3. Name suggests repository pattern conventions @Override public User findById(Long id) { if (cache.contains(id)) { return cache.get(id); // Enhancement: faster } User user = database.findById(id); // Same exception behavior cache.put(id, user); return user; // Preserves all expectations ✓ }}Understanding common ways developers fail to preserve expected behavior helps you avoid these pitfalls. Here are the most frequent failures, ranked by how often they cause production issues:
12345678910111213141516171819202122232425262728
// FAILURE 1: Returning nullclass ProductService { Product getById(String id) { return database.find(id); // Always returns Product }}class CachedProductService extends ProductService { @Override Product getById(String id) { Product p = cache.get(id); // BUG: Returns null on cache miss // Should fall back to database return p; }} // FAILURE 2: Throwing unexpectedlyclass ListWrapper extends ArrayList { @Override public void add(int index, Object o) { if (readOnly) { throw new UnsupportedOp(); } super.add(index, o); }}1234567891011121314151617181920212223242526272829
// FAILURE 3: Empty implementationclass MockLogger extends Logger { @Override void log(String msg) { // Nothing—tests pass but // logs are mysteriously empty }} // FAILURE 4: Different edge casesclass SmartDivider extends Divider { @Override double divide(double a, double b) { if (b == 0) { return 0; // Parent returns Inf } return a / b; }} // FAILURE 5: Async vs syncclass AsyncSaver extends FileSaver { @Override void save(String data) { executor.submit(() -> super.save(data)); // Returns before save completes! }}Knowing what to preserve is half the battle. Here are practical techniques for ensuring your subclasses maintain behavioral compatibility:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// TECHNIQUE 1: Call super methodsclass EnhancedLogger extends Logger { @Override public void log(String message) { // Preserve parent behavior super.log(message); // Add enhancement metrics.increment("log_count"); }} // TECHNIQUE 2: Template Method patternabstract class DataProcessor { // Final method controls structure public final void process(Data data) { validate(data); // Hook 1 transform(data); // Hook 2 persist(data); // Hook 3 sendNotification(data); // Parent controls this } // Subclasses customize these hooks protected abstract void validate(Data data); protected abstract void transform(Data data); protected abstract void persist(Data data); // Parent handles notification—behavior preserved private void sendNotification(Data data) { eventBus.publish(new DataProcessed(data)); }} // TECHNIQUE 3: Run parent tests against subclass@ParameterizedTest@ValueSource(classes = {ArrayList.class, LinkedList.class, MyList.class})void testListContract(Class<? extends List> listClass) { List<String> list = listClass.newInstance(); // All List implementations must pass these list.add("test"); assertEquals(1, list.size()); assertEquals("test", list.get(0)); list.remove(0); assertTrue(list.isEmpty());}Here's the ultimate heuristic for behavioral preservation:
If you replaced all uses of the parent class with your subclass, would any code behave differently?
This is the substitutability test in action. Let's break it down into practical questions:
...you likely have a behavioral preservation failure. This doesn't always mean your subclass is wrong—it may mean inheritance isn't the right relationship. Consider whether composition would be more appropriate, or whether you need a new type entirely rather than a subtype.
Remember: LSP isn't about restricting what subclasses can do. It's about ensuring that the IS-A relationship truly holds. If Square ISN'T a Rectangle in the behavioral sense (because setting width doesn't set height), then Square shouldn't extend Rectangle.
Behavioral preservation is how we ensure that 'extends' means what it should: Child can always substitute for Parent without breaking correctness.
What's Next:
Now that we understand what expected behavior is and how to preserve it, we'll examine the flip side: client expectations. How do clients form expectations? What implicit contracts do they depend on? And how do we design classes that make the right expectations easy to meet?
You now understand behavioral preservation—the practical skill at the heart of LSP compliance. Remember: compilers check syntax, but developers must ensure semantics. The goal isn't just subclasses that compile, but subclasses that work correctly wherever parents work.