Composite
It's a classic in the design patterns world, especially handy when you're dealing with tree-like structures.
The Composite Pattern is a structural design pattern, which means it's all about how objects are composed together to form larger structures. In simpler terms, it's a way to treat individual objects and compositions of objects uniformly.
When to Use It?​
- Hierarchical Structures: It shines in scenarios where you have hierarchical, tree-like structures. Think of file systems, UI components, or organizational structures.
- Uniform Treatment: When you want to treat individual objects and their compositions in the same way, the Composite Pattern is your go-to. This makes client code simpler, as it can treat composite structures and individual objects the same way.
How It Works​
- Component: This is an interface or an abstract class defining the common operations. Your individual objects and composites will implement or extend this.
- Leaf: These are the basic elements that don't have any children. They implement the operations defined in the Component.
- Composite: A component having children. It stores child components (which can be leaves or other composites) and implements the operations in the Component interface. The magic is in these methods, which typically delegate to their children.
Example in Web Dev: React Components​
Let's say you're building a UI component library in React. You can have a Component
interface, a Button
as a Leaf, and a Form
or Modal
as Composites containing Buttons and other elements.
Benefits​
- Simplicity: Clients use compositions and individual objects through the same interface, simplifying the client code.
- Flexibility: You can easily add new component types as long as they conform to the common interface.
Drawbacks​
- Overgeneralization: It can make your design overly general. Sometimes it's hard to restrict the components to only certain types.
- Potential for Confusion: The distinction between leaf and composite objects can become blurry.
In Summary​
The Composite Pattern is all about creating tree-like structures, allowing clients to treat individual objects and compositions of objects uniformly. It's a beautiful pattern for hierarchical systems, but like any pattern, it's not a one-size-fits-all solution. Use it when it truly fits your problem context.
Typesscript implementation​
We'll create a simple scenario with graphic objects in a UI framework. These objects can be either primitive shapes (like circles or rectangles) or complex ones (like groups of shapes). This example will demonstrate how both simple and complex objects can be treated uniformly using the Composite Pattern.
Structure​
- Graphic (Component): An abstract class or interface defining operations like
render
andmove
. - Circle, Rectangle (Leaf): Simple graphic objects without children. Implement the
Graphic
interface. These are your leaves. - Group (Composite): A complex graphic object that can contain any number of
Graphic
objects, including other groups. It maintains a collection ofGraphic
objects, which can be either leaves or other composites. Therender
andmove
methods are implemented to delegate the operation to its children.
// Component
interface Graphic {
render(): void;
move(x: number, y: number): void;
}
// Leaf
class Circle implements Graphic {
render(): void {
console.log('Rendering a circle');
}
move(x: number, y: number): void {
console.log(`Moving circle to (${x}, ${y})`);
}
}
class Rectangle implements Graphic {
render(): void {
console.log('Rendering a rectangle');
}
move(x: number, y: number): void {
console.log(`Moving rectangle to (${x}, ${y})`);
}
}
// Composite
class Group implements Graphic {
private children: Graphic[] = [];
render(): void {
console.log('Rendering a group of graphics');
for (const child of this.children) {
child.render();
}
}
move(x: number, y: number): void {
console.log(`Moving group to (${x}, ${y})`);
for (const child of this.children) {
child.move(x, y);
}
}
add(graphic: Graphic): void {
this.children.push(graphic);
}
remove(graphic: Graphic): void {
const index = this.children.indexOf(graphic);
if (index !== -1) {
this.children.splice(index, 1);
}
}
}
// Usage
const circle = new Circle();
const rectangle = new Rectangle();
const group = new Group();
group.add(circle);
group.add(rectangle);
group.render();
group.move(1, 2);
What's Happening?​
- We create a
Group
and add aCircle
and aRectangle
to it. - When we call
render
ormove
on theGroup
, these calls are delegated to each child, regardless of whether the child is a simple shape or another group.
This example illustrates how you can use the Composite Pattern to work with individual objects and compositions of objects uniformly. You can easily extend this pattern by adding more shapes or functionalities as needed.
FP implementation​
Implementing the Composite Pattern in a functional programming style in TypeScript is an interesting challenge since functional programming emphasizes immutability and function composition over object-oriented concepts. However, it's still very much doable. We'll use functions and immutable data structures instead of classes and mutable state.
- Graphic (Type): A type representing a graphic object. It could be a primitive shape or a group of shapes.
- render and move (Functions): Functions for rendering and moving graphics.
- createCircle, createRectangle, createGroup (Factory Functions): Functions to create different types of graphic objects.
// Defining types for different shapes and groups
type Position = { x: number; y: number };
type Circle = { kind: 'circle'; position: Position };
type Rectangle = { kind: 'rectangle'; position: Position };
type Group = { kind: 'group'; children: Graphic[] };
type Graphic = Circle | Rectangle | Group;
// Factory functions to create graphics
const createCircle = (position: Position): Circle => ({ kind: 'circle', position });
const createRectangle = (position: Position): Rectangle => ({ kind: 'rectangle', position });
const createGroup = (children: Graphic[]): Group => ({ kind: 'group', children });
// Function to render a graphic
const render = (graphic: Graphic): void => {
switch (graphic.kind) {
case 'circle':
console.log('Rendering a circle at', graphic.position);
break;
case 'rectangle':
console.log('Rendering a rectangle at', graphic.position);
break;
case 'group':
console.log('Rendering a group of graphics');
graphic.children.forEach(render);
break;
}
};
// Function to move a graphic
const move = (graphic: Graphic, newPosition: Position): Graphic => {
switch (graphic.kind) {
case 'circle':
case 'rectangle':
return { ...graphic, position: newPosition };
case 'group':
return { ...graphic, children: graphic.children.map(child => move(child, newPosition)) };
}
};
// Usage
const circle = createCircle({ x: 0, y: 0 });
const rectangle = createRectangle({ x: 10, y: 10 });
const group = createGroup([circle, rectangle]);
render(group);
const movedGroup = move(group, { x: 5, y: 5 });
render(movedGroup);
Explanation​
- Types and Factory Functions:
Circle
,Rectangle
, andGroup
are defined as types. Factory functionscreateCircle
,createRectangle
, andcreateGroup
are used to create these shapes. - render Function: A function that takes a
Graphic
and renders it based on its type. For a group, it recursively calls itself for each child. - move Function: A pure function that returns a new graphic object with an updated position. For a group, it recursively updates the position of each child.
Characteristics of this Approach​
- Immutability: The
move
function returns a new graphic object instead of modifying the existing one, adhering to the principles of functional programming. - Recursive Composition: Both
render
andmove
functions use recursion to handle groups of graphics, which is a common technique in functional programming for handling tree-like structures.
This functional approach to the Composite Pattern in TypeScript leverages type unions, recursion, and pure functions, providing a different perspective compared to the classical object-oriented approach.