Although I prefer using composition over OOP inheritance, especially in JavaScript, there's one thing to remember that is super important when subclassing: a subclass is supposed to extend its parent, not change the parent's behavior.
OOP languages can't fully enforce this rule on programmers but it's crucial. Having interfaces and static typing helps but it's up to the programmer to maintain this rule all the way through.
Here's an example of the issue in JavaScript:
//node:9
class Renderer {
constructor() {
if (this.constructor instanceof Renderer) {
throw new Error('Renderer class is abstract, should not be instantiated.')
}
this._accumulator = []
}
add(text) {
this._accumulator.push(text)
}
get() {
return this._accumulator
}
addList(texts = []) {
this.add(texts.join(','))
return this
}
addHeader(text = '') {
this.add(text)
return this
}
addParagraph(text = '') {
this.add(text)
return this
}
clear() {
this.get().length = 0
}
output() {
return this.get().join('\n')
}
}
class ConciseHTMLRenderer extends Renderer{
addList(texts = []) {
this.add(texts.map(text => `<li>${text}</li>`).join(''))
return this
}
addHeader(text = '') {
this.add(`<h1>${text}</h1>`)
return this
}
addParagraph(text = '') {
this.add(`<p>${text}</p>`)
return this
}
output() {
return this.get().join('')
}
//TODO: sanitize text before creating HTML from it
}
class MarkdownRenderer extends Renderer {
addList(texts = []) {
this.add(texts.map(text => `- ${text}`).join('\n'))
return this
}
addHeader(text = '') {
this.add(`# ${text}`)
return this
}
output() {
const parts = this.get()
const result = parts.join('\n\n')
this.clear()
return result
}
}
const test = (rendererObject) => {
console.log(`Testing ${rendererObject.constructor.name}`)
const document1 = rendererObject
.addHeader('Grocery List')
.addList(['eggs', 'bread', 'pasta'])
.output()
const document2 = rendererObject
.addHeader('Party Planning List')
.addList(['balloons', 'birthday hats', 'cake'])
.output()
console.log(`Document 1: ${document1}\n\nDocument 2: ${document2}\n------\n`)
}
test(new ConciseHTMLRenderer())
test(new MarkdownRenderer())
Output:
Testing ConciseHTMLRenderer
Document 1: <h1>Grocery List</h1><li>eggs</li><li>bread</li><li>pasta</li>
Document 2: <h1>Grocery List</h1><li>eggs</li><li>bread</li><li>pasta</li><h1>Party Planning List</h1><li>balloons</li><li>birthday hats</li><li>cake</li>
------
Testing MarkdownRenderer
Document 1: # Grocery List
- eggs
- bread
- pasta
Document 2: # Party Planning List
- balloons
- birthday hats
- cake
------
The Renderer
class is the parent of ConciseHTMLRenderer
and MarkdownRenderer
. It must be subclassed and its addList
, addHeader
, addParagraph
and output
methods can all be overridden if needed. Renderer
leverages the template method design pattern - it implements methods in a default and bare bones manner, allowing them to be easily overridden. Both ConciseHTMLRenderer
and MarkdownRenderer
render lists, headers and paragraphs differently, so they replace the default methods or, in the case of a markdown paragraph, leave the default implementation. Both subclasses keep the semantics of the parent intact with one, important exception.
The output
method is where MarkdownRenderer
breaks the semantics of its parent and diverges from ConciseHTMLRenderer
. The purpose of output
is to return the currently accumulated text. While MarkdownRenderer
's output
method does this as well, it also empties the accumulated output. Looking at MarkdownRenderer
from a client's perspective - a person who would treat MarkdownRenderer
as a black box and only use it in his code without knowing it's source code - the class will produce errors for him that are very difficult to debug. All because the purpose of output
was changed. Instead, MarkdownRenderer
could add an explicit outputAndClear
method. (It's not a perfect solution, because outputAndClear
is not a MarkdownRenderer
-specific method, but already an improvement.)