Hello once again! Today's lesson is centered around leveraging the principles of Object-Oriented Programming (OOP) — Encapsulation, Abstraction, Polymorphism, and Composition — using TypeScript to enhance code readability and structure. Buckle up for an exciting journey ahead!
OOP principles act as a scaffold for building readable, maintainable, and flexible code — these are the characteristics we seek while refactoring. By creating logical groupings of properties and behaviors in classes, we foster a codebase that's easier to comprehend and modify. Let's put this into perspective as we progress.
Encapsulation involves bundling related properties and methods within a class, thereby creating an organization that mirrors the real world.
Suppose we possess scattered student information within our program:
TypeScript1let studentName: string = "Alice"; 2let studentAge: number = 20; 3let studentGrade: number = 3.9; 4 5function displayStudentInfo(): void { 6 console.log("Student Name: " + studentName); 7 console.log("Student Age: " + studentAge); 8 console.log("Student Grade: " + studentGrade); 9} 10 11function updateStudentGrade(newGrade: number): void { 12 studentGrade = newGrade; 13}
Although functional, the code could cause potential confusion since the related attributes and behaviors aren't logically grouped. Let's encapsulate!
TypeScript1class Student { 2 private name: string; 3 private age: number; 4 private grade: number; 5 6 constructor(name: string, age: number, grade: number) { 7 this.name = name; 8 this.age = age; 9 this.grade = grade; 10 } 11 12 public displayStudentInfo(): void { 13 console.log("Student Name: " + this.name); 14 console.log("Student Age: " + this.age); 15 console.log("Student Grade: " + this.grade); 16 } 17 18 public updateStudentGrade(newGrade: number): void { 19 if (newGrade >= 0 && newGrade <= 4.0) { 20 this.grade = newGrade; 21 } else { 22 console.log("Invalid grade. Please provide a value between 0 and 4.0."); 23 } 24 } 25}
Access Modifiers: In the refactored class, the name
, age
, and grade
properties are marked as private
, ensuring they cannot be accessed or modified directly from outside the class. Instead, controlled access is provided via public methods (displayStudentInfo
and updateStudentGrade
).
Validation Logic: The updateStudentGrade
method includes basic validation to ensure grades fall within a valid range. This further demonstrates how encapsulation helps maintain data integrity.
Next up is Abstraction. It is about exposing the relevant features and concealing the complexities.
Consider a code snippet calculating a student's grade point average (GPA) through complex operations:
TypeScript1function calculateGpa(grades: string[]): number { 2 let totalPoints: number = 0; 3 const gradePoints: Record<string, number> = {'A': 4, 'B': 3, 'C': 2, 'D': 1, 'F': 0}; 4 5 grades.forEach((grade) => { 6 totalPoints += gradePoints[grade]; 7 }); 8 9 const gpa: number = totalPoints / grades.length; 10 return gpa; 11}
We can encapsulate this within the calculateGpa()
method of our Student
class, thereby simplifying the interaction.
TypeScript1class Student { 2 name: string; 3 grades: string[]; 4 gpa: number; 5 6 constructor(name: string, grades: string[]) { 7 this.name = name; 8 this.grades = grades; 9 this.gpa = this.calculateGpa(); 10 } 11 12 private calculateGpa(): number { 13 let totalPoints: number = 0; 14 const gradePoints: Record<string, number> = {'A': 4, 'B': 3, 'C': 2, 'D': 1, 'F': 0}; 15 16 this.grades.forEach((grade) => { 17 totalPoints += gradePoints[grade]; 18 }); 19 20 return totalPoints / this.grades.length; 21 } 22}
We can now access the gpa
as an attribute of the student object, which is calculated behind the scenes.
Polymorphism provides a unified interface for different types of actions, making our code more flexible.
Assume we are developing a simple graphics editor. Here is a code snippet without Polymorphism:
TypeScript1class Rectangle { 2 drawRectangle(): void { 3 console.log("Drawing a rectangle."); 4 } 5} 6 7class Triangle { 8 drawTriangle(): void { 9 console.log("Drawing a triangle."); 10 } 11}
We have different method names for each class. We can refactor this to have a singular draw()
method common to all shapes:
TypeScript1abstract class Shape { 2 abstract draw(): void; 3} 4 5class Rectangle extends Shape { 6 draw(): void { 7 console.log("Drawing a rectangle."); 8 } 9} 10 11class Triangle extends Shape { 12 draw(): void { 13 console.log("Drawing a triangle."); 14 } 15} 16 17// Polymorphism in action 18const shapes: Shape[] = [new Rectangle(), new Triangle()]; 19shapes.forEach(shape => shape.draw());
Now, regardless of the shape of the object, we can use draw()
to trigger the appropriate drawing behavior, thus enhancing flexibility.
Our last destination is Composition, which models relationships between objects and classes. Composition allows us to design our systems flexibly and maintainably by constructing complex objects from simpler ones. This principle helps us manage relationships by ensuring that objects are composed of other objects, thereby organizing dependencies more neatly and making individual parts easier to update or replace.
Consider a system in our application that deals with rendering various UI elements. Initially, we might have a Window
class that includes methods both for displaying the window and managing content, like buttons and text fields, directly within it.
TypeScript1class Window { 2 content: string; 3 4 constructor() { 5 this.content = "Default content"; 6 } 7 8 addTextField(content: string): void { 9 this.content = content; 10 } 11 12 display(): void { 13 console.log("Window displays: " + this.content); 14 } 15}
This approach tightly couples the window's display logic with the content management, making changes and maintenance harder as we add more elements and functionalities. Let's now see how we can update this code with composition.
To implement Composition, we decouple the responsibilities by creating separate classes for content management (ContentManager
) and then integrate these into our Window
class. This way, each class focuses on a single responsibility.
TypeScript1class ContentManager { 2 private content: string; 3 4 constructor(content: string = "Default content") { 5 this.content = content; 6 } 7 8 updateContent(newContent: string): void { 9 this.content = newContent; 10 } 11 12 getContent(): string { 13 return this.content; 14 } 15} 16 17class Window { 18 private manager: ContentManager; 19 20 constructor() { 21 this.manager = new ContentManager(); 22 } 23 24 display(): void { 25 console.log("Window displays: " + this.manager.getContent()); 26 } 27 28 changeContent(newContent: string): void { 29 this.manager.updateContent(newContent); 30 } 31}
By refactoring with Composition, we've encapsulated the content management within its own class. The Window
class now "has a" ContentManager
, focusing on displaying the window. This separation allows for easier modifications in how content is managed or displayed without altering the other's logic. Composition, in this way, enhances our system's flexibility and maintainability by fostering a cleaner and more modular design.
Great job! We've learned how to apply OOP principles using TypeScript to refactor code for improved readability, maintainability, and scalability.
Now, get ready for some exciting exercises. Nothing strengthens a concept better than practice! Happy refactoring!