So the AI era is here, the huge leap forward that, at the moment, spits Node code with const fetch = require('node-fetch') ? (true for both ChatGPT and Gemini as of today) and feeds yet another spin of the cyclic machine that is Internet and its content.
In that amalgamation of content, design patterns are appearing again
From posts explaining how to apply design patterns in Node(???) to posts explaining with all detail obsolete stuff like how to apply the factory pattern in Java (Java 8 released in March 2014 added Lambdas).
Ever stumbled upon Refactoring guru?
It is a website you probably visited in your learning journey through computer science, specially in programming. It's design patterns section is quite well explained and one of the most shared through different forums over the years.
If we go to the definition of what design patterns are, we find:
Design patterns are typical solutions to common problems
in software design. Each pattern is like a blueprint
that you can customize to solve a particular
design problem in your code.
Why this post, then? I mean, there's plenty of information on the website linked above; this could be all.
The thing is, I always struggled with accepting this definition... "to solve a particular design problem in my code"... in my code? Does my code have a problem that I need to solve?
What happens really, is that I need to code a certain "something" for which the programming language used in the project is lacking abstractions for.
Plain and simply. Just in case that doesn't resonate with you yet, lets see some examples with code.
This is a really simple implementation of the Factory Pattern in Java (primarily an Object Oriented programming language).
public class ShapeFactory { public Shape createShape(String type) { if (type.equalsIgnoreCase("CIRCLE")) { return new Circle(); } else if (type.equalsIgnoreCase("SQUARE")) { return new Square(); } return null; } }
Then Java 8 (March 2014, just in case you forgot) added Lambdas (a concept from functional programming) so we can do this instead:
Map<String, Supplier<Shape>> shapeFactory = new HashMap<>(); shapeFactory.put("CIRCLE", Circle::new); shapeFactory.put("SQUARE", Square::new); Shape circle = shapeFactory.get("CIRCLE").get();
No need for the factory design pattern ever again (at least in Java).
Yes I know the factory pattern is the example most people use all the time, but what happens with the others? And what happens in other programming languages?
This is the visitor pattern in Typescript:
interface Shape { draw(): void; accept(visitor: ShapeVisitor): void; } class Circle implements Shape { radius: number; constructor(radius: number) { this.radius = radius; } draw() { console.log("Drawing a circle"); } accept(visitor: ShapeVisitor) { visitor.visitCircle(this); } } class Square implements Shape { sideLength: number; constructor(sideLength: number) { this.sideLength = sideLength; } draw() { console.log("Drawing a square"); } accept(visitor: ShapeVisitor) { visitor.visitSquare(this); } } interface ShapeVisitor { visitCircle(circle: Circle): void; visitSquare(square: Square): void; } class AreaCalculator implements ShapeVisitor { private area = 0; visitCircle(circle: Circle) { this.area = Math.PI * circle.radius * circle.radius; console.log(`Circle area: ${this.area}`); } visitSquare(square: Square) { this.area = square.sideLength * square.sideLength; console.log(`Square area: ${this.area}`); } getArea(): number { return this.area; } } // Using the Visitor const circle = new Circle(5); const square = new Square(4); const calculator = new AreaCalculator(); circle.accept(calculator); square.accept(calculator);
The following code is doing exactly the same but using reflection (the ability of a language to examine and manipulate its own objects at runtime) instead of the Visitor pattern:
interface Shape { draw(): void; } class Circle implements Shape { // ... (same as before) radius: number; } class Square implements Shape { // ... (same as before) sideLength: number; } function calculateArea(shape: Shape) { if (shape instanceof Circle) { const circle = shape as Circle; // Type assertion const area = Math.PI * circle.radius * circle.radius; console.log(`Circle area: ${area}`); } else if (shape instanceof Square) { const square = shape as Square; // Type assertion const area = square.sideLength * square.sideLength; console.log(`Square area: ${area}`); } } const circle = new Circle(5); const square = new Square(4); calculateArea(circle); calculateArea(square);
Now the observer pattern, also in TypeScript:
interface Observer { update(data: any): void; } class NewsPublisher { private observers: Observer[] = []; subscribe(observer: Observer) { this.observers.push(observer); } unsubscribe(observer: Observer) { this.observers = this.observers.filter(o => o !== observer); } notify(news: string) { this.observers.forEach(observer => observer.update(news)); } } class NewsletterSubscriber implements Observer { update(news: string) { console.log(`Received news: ${news}`); } } // Using the Observer const publisher = new NewsPublisher(); const subscriber1 = new NewsletterSubscriber(); const subscriber2 = new NewsletterSubscriber(); publisher.subscribe(subscriber1); publisher.subscribe(subscriber2); publisher.notify("New product launched!");
The same but using the built-in (in the Node API) EventEmitter:
public class ShapeFactory { public Shape createShape(String type) { if (type.equalsIgnoreCase("CIRCLE")) { return new Circle(); } else if (type.equalsIgnoreCase("SQUARE")) { return new Square(); } return null; } }
At that point, you might have realized that the "problem" is the OOP implementation, and you would be quite right, but not fully.
Every programming paradigm, specially when taken in its most pure form, has its quirks, difficulties or "things that can't be achieved in a straight line", if you will.
Let's get ourselves to the functional programming realm. You've probably heard of Monads.
Whether you fell for the mathematical definition mind trap or not, we -software developers- could understand Monads as design patterns as well. This is because in a world of pure functions, where nothing unexpected happens, it's difficult to conceive a side effect, but most software products need side effects, so how do we...?
This is an example of the IO Monad in Haskell:
Map<String, Supplier<Shape>> shapeFactory = new HashMap<>(); shapeFactory.put("CIRCLE", Circle::new); shapeFactory.put("SQUARE", Square::new); Shape circle = shapeFactory.get("CIRCLE").get();
The side effect (reading a file) is contained in the IO monad.
Let's add a monadic example using typescript;
interface Shape { draw(): void; accept(visitor: ShapeVisitor): void; } class Circle implements Shape { radius: number; constructor(radius: number) { this.radius = radius; } draw() { console.log("Drawing a circle"); } accept(visitor: ShapeVisitor) { visitor.visitCircle(this); } } class Square implements Shape { sideLength: number; constructor(sideLength: number) { this.sideLength = sideLength; } draw() { console.log("Drawing a square"); } accept(visitor: ShapeVisitor) { visitor.visitSquare(this); } } interface ShapeVisitor { visitCircle(circle: Circle): void; visitSquare(square: Square): void; } class AreaCalculator implements ShapeVisitor { private area = 0; visitCircle(circle: Circle) { this.area = Math.PI * circle.radius * circle.radius; console.log(`Circle area: ${this.area}`); } visitSquare(square: Square) { this.area = square.sideLength * square.sideLength; console.log(`Square area: ${this.area}`); } getArea(): number { return this.area; } } // Using the Visitor const circle = new Circle(5); const square = new Square(4); const calculator = new AreaCalculator(); circle.accept(calculator); square.accept(calculator);
A classic one, I've seen the maybe monad like 50 times all over the Internet, but what is it, really?
The problem it's trying to solve:
interface Shape { draw(): void; } class Circle implements Shape { // ... (same as before) radius: number; } class Square implements Shape { // ... (same as before) sideLength: number; } function calculateArea(shape: Shape) { if (shape instanceof Circle) { const circle = shape as Circle; // Type assertion const area = Math.PI * circle.radius * circle.radius; console.log(`Circle area: ${area}`); } else if (shape instanceof Square) { const square = shape as Square; // Type assertion const area = square.sideLength * square.sideLength; console.log(`Square area: ${area}`); } } const circle = new Circle(5); const square = new Square(4); calculateArea(circle); calculateArea(square);
We forgot to define the properties of our object! ?
in a real use-case this would be the input from a side-effect mostly, like reading from a database or a file
So now if we do:
interface Observer { update(data: any): void; } class NewsPublisher { private observers: Observer[] = []; subscribe(observer: Observer) { this.observers.push(observer); } unsubscribe(observer: Observer) { this.observers = this.observers.filter(o => o !== observer); } notify(news: string) { this.observers.forEach(observer => observer.update(news)); } } class NewsletterSubscriber implements Observer { update(news: string) { console.log(`Received news: ${news}`); } } // Using the Observer const publisher = new NewsPublisher(); const subscriber1 = new NewsletterSubscriber(); const subscriber2 = new NewsletterSubscriber(); publisher.subscribe(subscriber1); publisher.subscribe(subscriber2); publisher.notify("New product launched!");
the program explodes.
The solution without the Maybe monad:
import { EventEmitter } from 'events'; class NewsPublisher extends EventEmitter { publish(news: string) { this.emit('news', news); } } const publisher = new NewsPublisher(); publisher.on('news', (news) => { console.log(`All subscribers received the news: ${news}`); }); publisher.publish("New product launched!");
The program does not explode.
The maybe monad is not necessary in JavaScript or typescript due to the optional chaining operator but if you're using a language that does not implement it... well, you can apply the maybe monad or shall I say design pattern?
Yes I know, there's people that just learnt the Maybe thingy and eagerly applied it to 6 side-projects all at once and now I'm being the giggle at the party for telling you "you don't need it". You can still use it though, in fact I invite you to do so if you feel it's cool (at the end of the day it's your code with that pretty face you can do whatever you want! ?)
But back to basics. What about other paradigms? If you're thinking outside the OOP/FP box, I like it!
All paradigms definitely have their own recurring solutions and techniques, even if they aren't always formally called "design patterns."
Here are a few examples (thanks Gemini for avoiding me thinking, thanks me for the pretty formatting and added value ?):
There are a lot of "techniques" and "patterns", this list is just to give you threads to pull if you're curious.
Hope you find this useful, read you rather soon!
While the term "design patterns" is most closely associated with OOP, other paradigms have their own sets of recurring solutions and techniques. These techniques address the specific challenges and constraints of those paradigms, providing established approaches to common problems. So, even if they aren't always formally labeled as "design patterns," they serve a similar purpose in guiding developers towards effective and maintainable solutions.
We can understand design patterns as well-known workarounds to patch features that the programming language we're using lacks abstractions for.
This post has been written almost entirely by me, specified examples by Gemini 1.5 Pro
The above is the detailed content of Demystifying Design Patterns. For more information, please follow other related articles on the PHP Chinese website!