Lesson 3

Object-Oriented Programming (OOP) Principles and Code Refactoring in Java

Lesson Overview

Hello once again! Today's lesson is centered around leveraging the principles of Object-Oriented Programming (OOP) — Encapsulation, Abstraction, Polymorphism, and Composition — to enhance code readability and structure. Buckle up for an exciting journey ahead!

Connection between OOP and Code Refactoring

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.

Applying Encapsulation for Better Code Organization

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.

Java
1String studentName = "Alice"; 2int studentAge = 20; 3double studentGrade = 3.9; 4 5public static void displayStudentInfo() { 6 System.out.println("Student Name: " + studentName); 7 System.out.println("Student Age: " + studentAge); 8 System.out.println("Student Grade: " + studentGrade); 9} 10 11public static void updateStudentGrade(double newGrade) { 12 studentGrade = newGrade; 13}

Although functional, the code could cause potential confusion as the related attributes and behaviors aren't logically grouped. Let's encapsulate!

Java
1public class Student { 2 private String name; 3 private int age; 4 private double grade; 5 6 public Student(String name, int age, double grade) { 7 this.name = name; 8 this.age = age; 9 this.grade = grade; 10 } 11 12 public void displayStudentInfo() { 13 System.out.println("Student Name: " + name); 14 System.out.println("Student Age: " + age); 15 System.out.println("Student Grade: " + grade); 16 } 17 18 public void updateStudentGrade(double newGrade) { 19 this.grade = newGrade; 20 } 21 22 // Getters and Setters 23 public String getName() { 24 return name; 25 } 26 27 public void setName(String name) { 28 this.name = name; 29 } 30 31 public int getAge() { 32 return age; 33 } 34 35 public void setAge(int age) { 36 this.age = age; 37 } 38 39 public double getGrade() { 40 return grade; 41 } 42 43 public void setGrade(double grade) { 44 this.grade = grade; 45 } 46}

After refactoring, all student-related properties and methods are contained within the Student class, thereby enhancing readability and maintainability.

Utilizing Abstraction to Simplify Code Interaction

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:

Java
1public static double calculateGpa(String[] grades) { 2 int totalPoints = 0; 3 Map<String, Integer> gradePoints = Map.of("A", 4, "B", 3, "C", 2, "D", 1, "F", 0); 4 for (String grade : grades) { 5 totalPoints += gradePoints.get(grade); 6 } 7 return (double) totalPoints / grades.length; 8}

We can encapsulate this within the calculateGpa() method of our Student class, thereby simplifying the interaction.

Java
1public class Student { 2 private String name; 3 private String[] grades; 4 private double gpa; 5 6 public Student(String name, String[] grades) { 7 this.name = name; 8 this.grades = grades; 9 this.gpa = calculateGpa(); 10 } 11 12 private double calculateGpa() { 13 int totalPoints = 0; 14 Map<String, Integer> gradePoints = Map.of("A", 4, "B", 3, "C", 2, "D", 1, "F", 0); 15 for (String grade : grades) { 16 totalPoints += gradePoints.get(grade); 17 } 18 return (double) totalPoints / grades.length; 19 } 20 21 public String getName() { 22 return name; 23 } 24 25 public void setName(String name) { 26 this.name = name; 27 } 28 29 public String[] getGrades() { 30 return grades; 31 } 32 33 public void setGrades(String[] grades) { 34 this.grades = grades; 35 this.gpa = calculateGpa(); 36 } 37 38 public double getGpa() { 39 return gpa; 40 } 41}

We can now access the gpa as an attribute of the student object, which is calculated behind the scenes.

Polymorphism to Cater for Multiple Behaviors

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:

Java
1public class Rectangle { 2 public void drawRectangle() { 3 System.out.println("Drawing a rectangle."); 4 } 5} 6 7public class Triangle { 8 public void drawTriangle() { 9 System.out.println("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:

Java
1public abstract class Shape { 2 public abstract void draw(); 3} 4 5public class Rectangle extends Shape { 6 @Override 7 public void draw() { 8 System.out.println("Drawing a rectangle."); 9 } 10} 11 12public class Triangle extends Shape { 13 @Override 14 public void draw() { 15 System.out.println("Drawing a triangle."); 16 } 17}

Now, regardless of the shape of the object, we can use draw() to trigger the appropriate drawing behavior, thus enhancing flexibility.

Building Better Structure with Composition

Our last destination is Composition, which models relationships between objects and classes. Composition allows us to design our systems in a flexible and maintainable way by constructing complex objects from simpler ones. This principle helps us manage relationships by ensuring that objects are composed of other objects, thus 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.

Java
1public class Window { 2 private String content; 3 4 public Window() { 5 this.content = "Default content"; 6 } 7 8 public void addTextField(String content) { 9 this.content = content; 10 } 11 12 public void display() { 13 System.out.println("Window displays: " + content); 14 } 15}

This approach tightly couples the window 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.

Refactoring with Composition

To implement Composition, we decouple the responsibilities by creating separate classes for content management (ContentManager) and then integrating these into our Window class. This way, each class focuses on a single responsibility.

Java
1public class ContentManager { 2 private String content; 3 4 public ContentManager() { 5 this.content = "Default content"; 6 } 7 8 public void updateContent(String newContent) { 9 this.content = newContent; 10 } 11 12 public String getContent() { 13 return content; 14 } 15} 16 17public class Window { 18 private ContentManager manager; 19 20 public Window() { 21 this.manager = new ContentManager(); 22 } 23 24 public void display() { 25 System.out.println("Window displays: " + manager.getContent()); 26 } 27 28 public void changeContent(String newContent) { 29 manager.updateContent(newContent); 30 } 31}

By refactoring with Composition, we've encapsulated the content management within its 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.

Summary

Great job! We've learned how to apply OOP principles 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!

Enjoy this lesson? Now it's time to practice with Cosmo!

Practice is how you turn knowledge into actual skills.