Skip to content

About the author of Daydream Drift

Tomasz Niezgoda (LinkedIn/tomaszniezgoda & GitHub/tniezg) is the author of this blog. It contains original content written with care.

Please link back to this website when referencing any of the materials.

Author:

Retaining Semantics In OOP

Published

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.)