Skip to main content

3 posts tagged with "java"

View All Tags

Mastering Design Patterns in Java

· 19 min read
  • In the world of software engineering, turning ideas into actual code can be tricky.

  • As developers, our goal is not just to make things work, but also to make sure our code is maintainable, scalable, adaptable and reusable.

  • Enter design patterns — the time-tested blueprints that empower us to tackle recurring design problems with elegance and efficiency.

  • At its heart, a design pattern is like a ready-made solution for common problems we face when designing software. These solutions are like shortcuts, saving us time and effort by using proven strategies that experts have refined over many years.

  • In this article, we’ll delve into some of the most important design patterns that every developer should be familiar with. We’ll explore their principles, why they’re useful, and how you can use them in real projects. Whether you’re struggling with creating objects, organizing relationships between classes, or managing how objects behave, there’s a design pattern that can help.

  • Let’s begin.

1. Singleton pattern

  • The Singleton pattern is a creational design pattern that ensures a class has only one instance and provides a global point of access to that instance. In simpler terms, it’s like ensuring there’s only one unique copy of a particular object in your program, and you can access that object from anywhere in your code.

  • Let’s take a simple real-world example: the clipboard. Picture multiple applications or processes running on a computer, each attempting to access the clipboard concurrently. If each application were to create its own version of the clipboard to manage copy and paste operations, it could lead to conflicting data.

public class Clipboard {

private String value;

public void copy(String value) {
this.value = value;
}

public String paste() {
return value;
}
}
  • In the above example, we've defined a Clipboard class capable of copying and pasting values. However, if we were to create multiple instances of Clipboard, each instance would hold its own separate data.
public class Main {
public static void main(String[] args) {

Clipboard clipboard1 = new Clipboard();
Clipboard clipboard2 = new Clipboard();

clipboard1.copy("Java");
clipboard2.copy("Design patterns");

System.out.println(clipboard1.paste()); // output: Java
System.out.println(clipboard2.paste()); // output: Design patterns
}
}
  • Clearly, this isn’t ideal. We expect both clipboard instances to display the same value. This is precisely where the Singleton pattern proves its worth.
public class Clipboard {

private String value;

private static Clipboard clipboard = null;

// Private constructor to prevent instantiation from outside
private Clipboard() {}

// Method to provide access to the singleton instance
public static Clipboard getInstance() {
if (clipboard == null) {
clipboard = new Clipboard();
}
return clipboard;
}

public void copy(String value) {
this.value = value;
}

public String paste() {
return value;
}
}
  • By implementing the Singleton pattern, we ensure that only one instance of the Clipboard class exists throughout the program execution.
public class Main {
public static void main(String[] args) {

// Getting the singleton instances
Clipboard clipboard1 = Clipboard.getInstance();
Clipboard clipboard2 = Clipboard.getInstance();

clipboard1.copy("Java");
clipboard2.copy("Design patterns");

System.out.println(clipboard1.paste()); // output: Design patterns
System.out.println(clipboard2.paste()); // output: Design patterns
}
}
  • Now, both clipboard1 and clipboard2 reference the same instance of the Clipboard class, ensuring consistency across the application.

2. Factory Design pattern

  • The Factory Design Pattern is a creational design pattern that provides an interface for creating objects in a super class but allows subclasses to decide which class to instantiate. In other words, it provides a way to delegate the instantiation logic to child classes.

  • Imagine you’re building a program that simulates a simple console based calculator. You have different types of operations like addition, subtraction, multiplication, division etc. Each operation has its own unique behavior. Now, you want to create these operation objects in your program based on customer choice.

  • The challenge is you need a way to create these operation objects without making your code too complex or tightly coupled. This means you don’t want your code to rely too heavily on the specific classes of operations directly. You also want to make it easy to add new types of operations later without changing a lot of code.

  • The Factory Design Pattern helps you solve this problem by providing a way to create objects without specifying their exact class. Instead, you delegate the creation process to a factory class.

  • Define the product interface. (Operation).

public interface Operation {
double calculate(double number1, double number2);
}
  • Implement concrete products for each operation.
// for addition
public class AddOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
return number1 + number2;
}
}

// for substration
public class SubOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
return number1 - number2;
}
}

// for multiplication
public class MulOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
return number1 * number2;
}
}

// for division
public class DivOperation implements Operation{
@Override
public double calculate(double number1, double number2) {
if(number2 == 0)
throw new ArithmeticException("Cannot divide by zero!");
return number1 / number2;
}
}

// An exception class invokes when user input invalid choice for operation
public class InvalidOperationException extends Exception{
public InvalidOperationException(String message) {
super(message);
}

}
  • Create a factory class (OperationFactory) with a method (getInstance) to create objects based on some parameter.
public interface OperationFactory {
Operation getInstance(int choice) throws InvalidOperation;
}

public class OperationFactoryImpl implements OperationFactory{
@Override
public Operation getInstance(int choice) throws InvalidOperationException {
if(choice==1)
return new AddOperation();
else if(choice==2)
return new SubOperation();
else if(choice==3)
return new MulOperation();
else if(choice==4)
return new DivOperation();
throw new InvalidOperation("Invalid operation selected!");
}
}
  • Use the factory to create objects without knowing their specific classes.
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
Output output = new ConsoleOutput();

try {

System.out.println("\n1. Addition(+)\n2. Subtraction(-)\n3. Multiplication(*)\n4. Division(/)");

// getting choice from user
System.out.println("\n\nSelect your operation (1-4): ");
int choice = scan.nextInt()

// getting 2 operands from user
System.out.println("Enter first operand: ");
double operand1 = scan.nextDouble();
System.out.println("Enter second operand: ");
double operand2 = scan.nextDouble();

// create opeartion instance based on user choice
OperationFactory operationFactory = new OperationFactoryImpl();
Operation operation = operationFactory.getInstance(choice);

// printing result
System.out.println("\nThis result is " + operation.calculate(operand1, operand2) + ".");
}
catch (InputMismatchException e) {
System.out.println("Invalid input type!\n");
}
catch (InvalidOperation | ArithmeticException e) {
System.out.println(e.getMessage());
}

scan.close();
}
  • Here the Main class demonstrates the usage of the factory to create different operation objects without knowing their specific implementation classes (Loose coupling).
  • It only interacts with the factory interface. Not only that, but we can also easily add new types of operations without changing existing client code. We are just needed to create a new concrete product and update the factory if necessary.

3. Builder pattern

  • The Builder Pattern provides a way to construct an object by allowing you to set its various properties (or attributes) in a step-by-step manner.

  • Some of the parameters might be optional for an object, but we are forced to send all the parameters and optional parameters need to send as NULL. We can solve this issue with large number of parameters by providing a constructor with required parameters and then different setter methods to set the optional parameters.

  • This pattern is particularly useful when dealing with objects that have many optional parameters or configurations.

  • Imagine we’re developing a user entity. Users have different properties like name, email, phone and city etc. Here name and email are required fields and phone and city are optional. Now, each person might have different combinations of these properties. Some might have city, others might not. Some might have phone, others might not. The Builder Design Pattern helps you create these users flexibly, step by step.

// Main product class
public class User {
private String name; // required field
private String email; // required field
private String phone; // optional field
private String city; // optional field

public User(UserBuilder userBuilder) {
this.name = userBuilder.getName();
this.email = userBuilder.getEmail();
this.phone = userBuilder.getPhone();
this.city = userBuilder.getCity();
}

public static UserBuilder builder(String name, String email) {
return new UserBuilder(name, email);
}

@Override
public String toString() {
return "User = " +
"{ name: '" + name + '\'' +
", email: '" + email + '\'' +
", phone: '" + phone + '\'' +
", city: '" + city + '\'' +
" }";
}

// builder class
public static class UserBuilder {
private String name; // required field
private String email; // required field
private String phone = "unknown"; // optional field
private String city = "unknown"; // optional field

public UserBuilder(String name, String email) {
this.name = name;
this.email = email;
}

// getters

public UserBuilder name(String name) {
this.name = name;
return this;
}

public UserBuilder email(String email) {
this.email = email;
return this;
}

public UserBuilder phone(String phone) {
this.phone = phone;
return this;
}

public UserBuilder city(String city) {
this.city = city;
return this;
}

public User build() {
return new User(this);
}
}

}
  • UserBuilder class: Is the inner builder class responsible for constructing User objects. It has fields representing the presence or absence of different properties (name, email, phone, city). The class provides setter methods for each properties, which return the builder itself (name(), phone(), city(),email() This enables method chaining.
  • User class: Is the class represents the product you want to build using the builder pattern. It has private fields to represent the properties of the user (name, email, phone, city). The constructor of User takes a UserBuilder object and initializes its fields based on the builder's settings. There is a static method builder() that returns a new instance of UserBuilder, providing a convenient way to create a new builder.
  • Here’s an example of how you can use this code to create a user with optional properties:
public class Main {
public static void main(String[] args) {

User user1 = User
.builder("John", "john@abc@gmail.com")
.build();

System.out.println(user1); // User = { name: 'John', email: 'john@abc@gmail.com', phone: 'unknown', city: 'unknown' }

User user2 = User
.builder("Mary", "mary@abc@gmail.com")
.city("Colombo")
.build();

System.out.println(user2); // User = { name: 'Mary', email: 'mary@abc@gmail.com', phone: 'unknown', city: 'Colombo' }
}

}
  • So that’s what builder patterns is guys. This pattern is useful when you have complex objects with many optional parameters, and it helps keep your code clean and easy to understand. It allows you to construct different variations of objects with the same builder, adjusting parameters as needed.

4. Adapter pattern

  • The Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.

  • Imagine a situation where two classes or components perform similar tasks but have different method names, parameter types, or structures. The Adapter pattern allows these incompatible interfaces to work together by providing a wrapper (the adapter) that translates the interface of one class into an interface that the client expects.

  • Target is the interface expected by the client.

  • Adaptee is the class that needs to be adapted.

  • Adapter is the class that implements the Target interface and wraps the Adaptee class.

  • Client class is the class that uses the adapter to interact with the Adaptee through the Tareget interface.

// Target interface
interface CellPhone {
void call();
}

// Adaptee (the class to be adapted)
class FriendCellPhone {
public void ring() {
System.out.println("Ringing");
}
}

// Adapter class implementing the Target interface
class CellPhoneAdapter implements CellPhone {
private FriendCellPhone friendCellPhone;

public CellPhoneAdapter(FriendCellPhone friendCellPhone) {
this.friendCellPhone = friendCellPhone;
}

@Override
public void call() {
friendCellPhone.ring();
}
}

// Client class
public class AdapterMain {
public static void main(String[] args) {
// Using the adapter to make Adaptee work with Target interface
FriendCellPhone adaptee = new FriendCellPhone();
CellPhone adapter = new CellPhoneAdapter(adaptee);
adapter.call();
}
}

In this example:

  • CellPhone is the target interface that your client code expects, and you do not have an implementation of it.
  • FriendCellPhone is the class you want to adapt/reuse (the Adaptee), which has a method named ring rather than creating new implementaion of CellPhone interface.
  • CellPhoneAdapter is the adapter class that implements the CellPhone interface and wraps an instance of FriendCellPhone. The call method in the adapter delegates the call to the ring method of the FriendCellPhone class.
  • AdapterMain class serves as the client that demonstrates the usage of the Adapter pattern in action.


Why adapter pattern?

  • The Adaptee might be a class from a third-party library or a legacy codebase that you can’t modify directly. By using an adapter, you can adapt its interface to match the interface expected by the client without modifying the original code.
  • The client might only require specific functionality from the Adaptee. By using an adapter, you can provide a tailored interface that exposes only the necessary functionality, rather than exposing the entire interface of the Adaptee.
  • It might seem that you can achieve similar functionality by creating an instance of the Target interface directly, using an adapter provides benefits in terms of code reusability, maintainability, and flexibility, especially when dealing with existing code or third-party libraries.

5. Decorator pattern

  • The Decorator Pattern is a design pattern in object-oriented programming that allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class.

  • In this pattern, there is a base class (or interface) that defines the common functionality, and one or more decorator classes that add additional behavior. These decorator classes wrap the original object, augmenting its behavior in a modular and flexible way.

  • Imagine, you are tasked with creating a drawing application that allows users to create and customize shapes with various decorations. It should be able to easily add new decorators for additional features without changing the existing code for shapes or other decorators.

  • Let’s see how we can achieve that using decorator pattern.

// Shape Interface
interface Shape {
void draw();
String getName();
}

// Concrete Shape: Circle
class Circle implements Shape {
private String name;

public Circle(String name) {
this.name = name;
}

public String getName() {
return name;
}

@Override
public void draw() {
System.out.println("Drawing circle, " + getName() + ".");
}
}
  • Shape Interface: Defines the basic operations that all shapes should support. In this case, it includes the draw() method to draw the shape and getName() to get the name of the shape.
  • Circle Class: Implements the Shape interface, representing a concrete shape (in this case, a circle). It has a name attribute and implements the draw() method to draw a circle.
// Abstract Decorator Class
abstract class ShapeDecorator implements Shape {
private Shape decoratedShape;

public ShapeDecorator(Shape decoratedShape) {
this.decoratedShape = decoratedShape;
}

@Override
public void draw() {
decoratedShape.draw();
}

@Override
public String getName() {
return decoratedShape.getName();
}
}
  • ShapeDecorator Abstract Class: An abstract class implementing the Shape interface. It contains a reference to a Shape object (the decorated shape) and delegates the draw() method to this object.
// Concrete Decorator: BorderDecorator
class BorderDecorator extends ShapeDecorator {
private String color;
private int widthInPxs;

public BorderDecorator(Shape decoratedShape, String color, int widthInPxs) {
super(decoratedShape);
this.color = color;
this.widthInPxs = widthInPxs;
}

@Override
public void draw() {
super.draw();
System.out.println("Adding " + widthInPxs + "px, " + color + " color border to " + getName() + ".");
}
}

// Concrete Decorator: ColorDecorator
class ColorDecorator extends ShapeDecorator {
private String color;

public ColorDecorator(Shape decoratedShape, String color) {
super(decoratedShape);
this.color = color;
}

@Override
public void draw() {
super.draw();
System.out.println("Filling with " + color + " color to " + getName() + ".");
}
}
  • BorderDecorator and ColorDecorator Classes: Concrete decorator classes that extend ShapeDecorator. They add additional features to the decorated shapes, such as borders and colors. They override the draw() method to add their specific functionality while also calling the draw() method of the decorated shape.
// Main Class
public class DecoratorMain {
public static void main(String[] args) {
// Create a circle
Shape circle1 = new Circle("circle1");

// Decorate the circle with a border
Shape circle1WithBorder = new BorderDecorator(circle1, "red", 2);

// Decorate the circle with a color
Shape circle1WithBorderAndColor = new ColorDecorator(circle1WithBorder, "blue");

// Draw the decorated circle
circle1WithBorderAndColor.draw();

// output
// Drawing circle, circle1.
// Adding 2px, red color border to circle1.
// Filling with blue color to circle1.
}
}
  • DecoratorMain Class: Contains the main() method where the decorator pattern is demonstrated. It creates a circle, decorates it with a border, and then further decorates it with a color. Finally, it calls the draw() method to visualize the decorated shape.
  • Now, with the implementation of the Decorator Pattern, our drawing application gains the remarkable ability to embellish not only circles but also a plethora of geometric shapes such as rectangles, triangles, and beyond. Moreover, the extensibility of this pattern enables us to seamlessly integrate additional decorators, offering features like transparency, diverse border styles (solid, dotted), and much more. This dynamic enhancement capability, achieved without altering the core structure of the shapes, underscores the pattern’s prowess in promoting code reusability, flexibility, and scalability.

6. Observer pattern

  • The Observer Pattern a behavioral design pattern commonly used in object-oriented programming to establish a one-to-many dependency between objects. In this pattern, one object (called the subject or observable) maintains a list of its dependents (observers) and notifies them of any state changes, usually by calling one of their methods.

Here’s how it works:

  • Subject: This is the object that holds the state and manages the list of observers. It provides methods to attach, detach, and notify observers.

  • Observer: This is the interface that defines the method(s) that the subject calls to notify the observer of any state changes. Typically, observers implement this interface.

  • Concrete Subject: This is the concrete implementation of the subject interface. It maintains the state and sends notifications to observers when the state changes.

  • Concrete Observer: This is the concrete implementation of the observer interface. It registers itself with a subject to receive notifications and implements the update method to respond to state changes.

  • In the context of a YouTube channel subscriber scenario, the YouTube channel is the subject, and the subscribers are the observers. When an event happens in a YouTube channel, it notifies all its subscribers about the new video so they can watch it.

  • Let’s implement this example in code,

public enum EventType {
NEW_VIDEO,
LIVE_STREAM
}

public class YoutubeEvent {
private EventType eventType;
private String topic;

public YoutubeEvent(EventType eventType, String topic) {
this.eventType = eventType;
this.topic = topic;
}

// getters ans setters

@Override
public String toString() {
return eventType.name() + " on " + topic;
}
}
  • EventType: The EventType enum defines the types of events that can occur, such as NEW_VIDEO , LIVE_STREAM and more.
  • Event: The YoutubeEvent class represents the events that occur in the system. It contains information such as the type of event and the topic.
public interface Subject {

void addSubscriber(Observer observer);
void removeSubscriber(Observer observer);
void notifyAllSubscribers(YoutubeEvent event);

}

public interface Observer {
void notifyMe(String youtubeChannelName, YoutubeEvent event);
}

  • Subject: The Subject interface declares methods to manage subscribers (addSubscriber and removeSubscriber) and to notify them (notifyAllSubscribers) when an event occurs.
  • Observer: The Observer interface declares a method (notifyMe) that subjects call to notify observers of any change in state.
public class YoutubeChannel implements Subject{

private String name;
private List<Observer> subscribers = new ArrayList<>();

public YoutubeChannel(String name) {
this.name = name;
}

public String getName() {
return name;
}

@Override
public void addSubscriber(Observer observer) {
subscribers.add(observer);
}

@Override
public void removeSubscriber(Observer observer) {
subscribers.remove(observer);
}

@Override
public void notifyAllSubscribers(YoutubeEvent event) {
for(Observer observer: subscribers) {
observer.notifyMe(getName(), event);
}
}
}
  • Concrete Subject: The YoutubeChannel class implements the Subject interface. It maintains a list of subscribers and notifies them when a new event occurs. package observer;
public class YoutubeSubscriber implements Observer{
private String name;

public YoutubeSubscriber(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

@Override
public void notifyMe(String youtubeChannelName, YoutubeEvent event) {
System.out.println("Dear " + getName() + ", Notification from " + youtubeChannelName + ": " + event);
}
}
  • Concrete Observer: The YoutubeSubscriber class implements the Observer interface. It defines the behavior to be performed when notified by a subject.
public class ObserverMain {
public static void main(String[] args) throws InterruptedException {
YoutubeChannel myChannel = new YoutubeChannel("MyChannel");

Observer john = new YoutubeSubscriber("John");
Observer bob = new YoutubeSubscriber("Bob");
Observer tom = new YoutubeSubscriber("Tom");

myChannel.addSubscriber(john);
myChannel.addSubscriber(bob);
myChannel.addSubscriber(tom);

myChannel.notifyAllSubscribers(new YoutubeEvent(EventType.NEW_VIDEO, "Design patterns"));
myChannel.removeSubscriber(tom);
System.out.println();
Thread.sleep(5000);
myChannel.notifyAllSubscribers(new YoutubeEvent(EventType.LIVE_STREAM, "JAVA for beginners"));

}
}
  • Main Class: The ObserverMain class contains the main method where we test our implementation. It creates a YoutubeChannel instance, adds subscribers to it, notifies them of new video event, and removes one of the subscribers and again notifies them of a live stream event.
// output
Dear John, Notification from MyChannel: NEW_VIDEO on Design patterns
Dear Bob, Notification from MyChannel: NEW_VIDEO on Design patterns
Dear Tom, Notification from MyChannel: NEW_VIDEO on Design patterns

Dear John, Notification from MyChannel: LIVE_STREAM on JAVA for beginners
Dear Bob, Notification from MyChannel: LIVE_STREAM on JAVA for beginners
  • By using the Observer design pattern, the YouTube channel can easily notify all its subscribers whenever a new video is uploaded without tightly coupling the channel and its subscribers. This promotes a more flexible and maintainable design.

Conclusion

In conclusion, design patterns are indispensable tools for Java developers, offering proven solutions to recurring design problems and promoting code reusability, maintainability, and scalability. By understanding and implementing these patterns effectively, developers can craft robust, flexible, and easily maintainable software solutions. While mastering design patterns requires practice and experience, the benefits they bring to software development are invaluable. Whether you’re working on a small project or a large-scale enterprise application, leveraging design patterns empowers you to write cleaner, more efficient code and ultimately become a more proficient Java developer.

Mastering OOP concepts in JAVA

· 23 min read

Programing paradigms are approaches to write code, each with its own principles, concepts and guidelines. These paradigms guide how developers structure and organize their programs, as well as how they think about problem-solving. Here are some common programming paradigms.

  • Imperative Programming: Imperative programming is based on the idea of giving the computer a sequence of instructions to perform. Eg., C, Assembly
  • Declarative Programing: Declarative programming emphasizes expressing what should be accomplished rather than how to achieve it. Eg., SQL
  • Functional Programming: Functional programming treats computation as the evaluation of mathematical functions and avoids changing state and mutable data. It emphasizes the use of pure functions, higher-order functions, and immutable data structures. Eg., Haskell, Lisp, and Clojure
  • Object-Oriented Programming (OOP): Object-oriented programming organizes code around objects, representing real-world entities or abstract concepts. Eg., Java, Python, C++
  • Procedural Programming: Procedural programming emphasizes the use of procedures (functions or routines) to structure code. It focuses on breaking down a problem into a set of procedures that perform specific tasks, with an emphasis on modularity and reusability. Eg., C, Fortran
  • Event-Driven Programming: Event-driven programming focuses on responding to events or user actions, such as mouse clicks or keyboard inputs. It typically involves event listeners or handlers that execute in response to specific events. GUI (Graphical User Interface) programming often follows this paradigm.

In this article I am going to discuss about one of the most important programing paradigms, Object Oriented Programing (OOP) in JAVA.

As I mentioned earlier,

OOP, which is a programming paradigm or methodology used in software development whose fundamental building blocks are objects.

  • OOP promotes the organization of code into modular and reusable components, making it easier to manage and maintain complex software systems. OOP is widely used in software development for its ability to model real-world entities and their relationships effectively.

  • OOP is based on many key principles. Let’s go through one-by-one principles with explanations and examples.

Object

  • In Object-Oriented Programming (OOP), objects are the fundamental building blocks and the key concept.
  • Objects represent real-world entities or concepts in the context of a software program.
  • Examples: Student, Book, Hospital, Cart and so on…

Class

  • An object can have 2 things to describe itself. They are properties and behaviors.
  • Imagine you’re talking about a cat. One might be sleek and black, while another is fluffy and brown. Each cat has its own unique combination of traits.
  • So, how do we capture the essence of a cat in a way that fits all these variations? Enter the concept of a ‘class.’
  • A class is like a blueprint for creating objects. It’s a plan that defines the properties (like color, size, and breed) and behaviors (such as meowing, sleeping, and chasing mice) that all cats shares.
  • Think of class as a template that provides a common idea of what a particular object is, allowing us to create individual instances of that object with their own distinct characteristics.
  • Classes in object-oriented programming (OOP) represent properties and behaviors of objects through attributes and methods, respectively.
  • Properties (Attributes): Properties are the characteristics or data associated with an object. In classes, properties are defined as variables. Each instance of a class (object) has its own set of properties.For example, in a class representing a “Student”, properties could include “name”, “age”, “major”, and “GPA”.
  • Behaviors (Methods): Behaviors are the actions or operations that an object can perform. In classes, behaviors are defined as methods or functions. Methods operate on the data stored in the object’s properties. For example, in the “Student” class, methods could include “study”, “attend class”, “take exam”, and “submit assignment”.

Let’s see a simple example in Java to illustrate how a class represents properties and behaviors.

// Define a class representing a Student
public class Student {
// Properties (attributes)
private String name;
private int age;
private String major;

// Method to study
public void study() {
System.out.println(name + " is studying " + major + ".");
}

// Method to attend class
public void attendClass() {
System.out.println(name + " is attending class.");
}

// Method to take an exam
public void takeExam() {
System.out.println(name + " is taking an exam.");
}

// Method to submit assignment
public void submitAssignment() {
System.out.println(name + " is submitting an assignment.");
}

}
  • In this example, the “Student” class represents a student with properties (name, age, major) and behaviors (study, attendClass, takeExam, submitAssignment). Each instance of the “Student” class will have its own set of properties and can perform the defined behaviors.

  • Now that we’ve defined our class, let’s dive into how we can bring it to life by creating instances of it — essentially, the objects themselves. This process introduces us to a vital concept in OOP: the constructor.

Constructors

  • A constructor is a special method within a class responsible for initializing new objects.
  • Think of it as the gateway through which we breathe life into our class, providing initial values for its properties.
  • When we create a new instance of a class, we call upon its constructor to set up the object’s initial state.
  • Constructors have the same name as the class and do not have a return type, not even void.
  • To create a new student object, we use the ‘new’ keyword followed by the class name, along with any required arguments for the constructor.
  • There are different types of constructors:

Default Constructor:

  • If a class does not explicitly define any constructors, Java provides a default constructor with no arguments. The default constructor initializes the object’s attributes to default values (e.g., numeric types to 0, object references to null).
// Define a class representing a Student
public class Student {
// Properties (attributes)
private String name;
private int age;
private String major;

// Default Constructor
// (will discuss about `this` keyword later in this aricle)
public Student() {
this.name = "Unknown";
this.age = 0;
this.major = "Undeclared";
}

// Other methods...

}
// Creating a student object using the default constructor
Student john = new Student();

Parameterized Constructors:

  • Constructors can accept parameters to initialize the object with specific values. You can define multiple constructors with different parameter lists, allowing for constructor overloading.
public class Student {
private String name;
private int age;
private String major;

// Parameterized Constructor
public Student(String name, int age, String major) {
this.name = name;
this.age = age;
this.major = major;
}

// Other methods...
}
// Creating a student object using the parameterized constructor
Student alice = new Student("Alice", 20, "Computer Science");

Copy constructor:

  • A copy constructor creates a new object by copying the values of another object. It’s used to create a new object that is a copy of an existing one.
public class Student {
private String name;
private int age;
private String major;

// Copy Constructor
public Student(Student otherStudent) {
this.name = otherStudent.name;
this.age = otherStudent.age;
this.major = otherStudent.major;
}

// Other methods...
}

// Creating a student object using another student object (copy constructor)
Student bob = new Student(alice);

Now, we have created objects right. How can we access the properties of a particular object? Let’s check it out next.

Accessing attributes and methods of an object

  • To access the properties of a particular object in Java, we can use dot notation (.) followed by the name of the attribute or method name.
// Creating a student object using the parameterized constructor
Student alice = new Student("Alice", 20, "Computer Science");

// Accessing properties of the 'alice' object
String aliceName = alice.name;
int aliceAge = alice.age;
String aliceMajor = alice.major;

// modifing properties of the 'alice' object
alice.name = "Alice Mark";
alice.age = 22;
alice.major = "Software Engineering";

// Accessing methods of the 'alice' object
alice.study();
alice.attendClass();
alice.takeExam();
alice.submitAssignment();

'this' keyword in JAVA

  • The this keyword in Java is a reference to the current object within a method or constructor. It's a special reference that allows you to access the current object's properties, methods, or constructors from within its own class.
  • Accessing Instance Variables: You can use this to refer to instance variables of the current object when there's a naming conflict with method parameters or local variables.
public class Student {
private String name;

public void setName(String name) {
this.name = name; // changed the name of the object which called this method
}
}
  • Calling Another Constructor: In a constructor, you can use this() to call another constructor in the same class. This is often used to reduce code duplication and initialize common properties.
public class Student {
private String name;
private int age;
private String major;

// modified default constructor
public Student() {
this("Unknown", 0, "Undeclared"); // Calls the parameterized constructor with default values
}

public Student(String name, int age) {
this.name = name;
this.age = age;
}
}
  • Passing Current Object as a Parameter: You can use this to pass the current object as a parameter to other methods or constructors.
public class Student {
private String name;

public void printName() {
printFullName(this); // Passing the current object as a parameter
}

private void printFullName(Student student) {
System.out.println(student.name);
}
}

Access Modifiers

  • Access modifiers are keywords used to control the visibility or accessibility of classes, methods, and variables within a Java program.

  • They determine which parts of your code can be accessed or modified from other parts of your program, as well as from external code.

  • Java has four main access modifiers:

    1. public: The public access modifier makes the class, method, or variable accessible from any other class.

    2. protected: The protected access modifier allows access to the member within the same package or by subclasses (will discuss later about subclasses), even if they are in a different package. It restricts access to classes outside the package unless they are subclasses of the class containing the protected member.

    3. default (no modifier): If no access modifier is specified, the default access level is package-private. Members with default access are accessible only within the same package. They cannot be accessed from outside the package, even by subclasses.

    4. private: The private access modifier restricts access to the member only within the same class. It is the most restrictive access level and prevents access from outside the class, including subclasses.

let’s demonstrate the use of access modifiers with the Student class we used earlier.

public class Student {
// Public access modifier
public String name;

// Protected access modifier
protected int age;

// Default (package-private) access modifier
String major;

// Private access modifier
private double gpa;

// Constructor
public Student(String name, int age, String major, double gpa) {
this.name = name;
this.age = age;
this.major = major;
this.gpa = gpa;
}

// Public method
public void displayInfo() {
System.out.println("Name: " + name);
System.out.println("Age: " + age);
System.out.println("Major: " + major);
System.out.println("GPA: " + gpa);
}

// Protected method
protected void study() {
System.out.println(name + " is studying.");
}

// Default method (package-private)
void attendClass() {
System.out.println(name + " is attending class.");
}

// Private method
private void calculateGPA() {
// GPA calculation logic
}
}

Encapsulation

  • Encapsulation refers to the bundling of data (attributes) and methods (functions) that operate on an object into a single unit, often referred to as a class.
  • Encapsulation hides the internal state and implementation details of an object from the outside world, providing controlled access to the object’s properties and behaviors.
  • The primary goal of encapsulation is to restrict access to some of the object’s components, while exposing only what is necessary and safe for the outside world.
  • In Java, encapsulation is achieved using access modifiers (public, private, protected, and default) to control the visibility and accessibility of class members (attributes and methods).
  • key principles and practices related to encapsulation in Java:
    1. Declare the attributes (fields) of a class as private.
    2. Provide public methods (getters and setters) to access and manipulate the private fields. Getters allow read-only access, and setters allow modification, ensuring controlled access to the data.
    3. Let’s achieve encapsulation in previously discussed Car class by declaring attributes as private and providing getters and setters .
public class Student {
// Private data members (attributes)
// hence cannot access or modify directly using dot (.) operator
private String name;
private int age;
private String major;

// Constructor
public Student(String name, int age, String major) {
this.name = name;
this.age = age;
this.major = major;
}

// Getter methods (accessors)
public String getName() {
return name;
}

public int getAge() {
return age;
}

public String getMajor() {
return major;
}

// Setter methods (mutators)
public void setName(String name) {
this.name = name;
}

public void setAge(int age) {
this.age = age;
}

public void setMajor(String major) {
this.major = major;
}

// Display student information
public void displayInfo() {
System.out.println("Name: " + name);
System.out.println("Age: " + age);
System.out.println("Major: " + major);
System.out.println("GPA: " + gpa);
}

}
  • The Student class encapsulates data members (name, age, major) by declaring them as private.
  • Getter methods (getName(), getAge(), getMajor()) provide controlled access to retrieve the values of the private attributes.
  • Setter methods (setName(), setAge(), setMajor()) allow controlled modification of the private attributes, ensuring data integrity.
public class Main {
public static void main(String[] args) {
// Creating a new student object
Student alice = new Student("Alice", 20, "Computer Science", 3.5);

// Displaying Alice's information using getter methods
System.out.println("Student Information:");
System.out.println("Name: " + alice.getName());
System.out.println("Age: " + alice.getAge());
System.out.println("Major: " + alice.getMajor());
System.out.println("GPA: " + alice.getGpa());

// Updating Alice's information using setter methods
alice.setAge(21);
alice.setGpa(3.7);

// Displaying Alice's updated information
System.out.println("\nUpdated Student Information:");
alice.displayInfo();
}
}
  • Encapsulation is a key principle in Java and other object-oriented languages that promotes data integrity, code maintainability, and code security by controlling access to the internal state of objects. It is an essential practice for creating well-structured and robust Java programs.

Inheritance

  • Inheritance is a key concept in object-oriented programming (OOP) that allows a new class (called a subclass or derived class) to inherit attributes and methods from an existing class (called a superclass or base class).
  • The subclass can then extend or modify the behavior of the superclass while also inheriting its properties.
  • Superclass (Base Class): The class whose members (attributes and methods) are inherited by another class is known as the superclass or base class.
  • Subclass (Derived Class): The class that inherits the members from a superclass is called the subclass or derived class. A subclass can have its own additional members and can also override or extend the members inherited from the superclass.
  • IS-A Relationship: Inheritance establishes an “is-a” relationship between the subclass and the superclass, indicating that the subclass is a specialized version of the superclass. For example, if Dog is a subclass of Animal, then it can be said that "a dog is an animal."
  • “extends” Keyword: In Java, you specify inheritance using the “extends” keyword when defining a class. A subclass is created as a specialization of the superclass.
  • “super” keyword: In Java, the super keyword is used to refer to the superclass (parent class) of the current subclass (child class). It allows you to access and call members (attributes or methods) of the superclass, as well as explicitly call the superclass’s constructor.

Let’s create a subclass of the Student class called UndergraduateStudent.

public class UndergraduateStudent extends Student {
// Additional attributes specific to undergraduate students
private int yearLevel;

// Constructor for UndergraduateStudent
public UndergraduateStudent(String name, int age, String major, int yearLevel) {
// Call the constructor of the superclass (Student)
super(name, age, major);
this.yearLevel = yearLevel;
}

// Getter method for yearLevel
public int getYearLevel() {
return yearLevel;
}

// Setter method for yearLevel
public void setYearLevel(int yearLevel) {
this.yearLevel = yearLevel;
}

// Method specific to undergraduate students
public void enrollCourse(String courseName) {
System.out.println(getName() + " is enrolled in " + courseName);
}
}

let’s create another subclass of the Student class called GraduateStudent.

public class GraduateStudent extends Student {
// Additional attributes specific to graduate students
private String advisor;
private String researchTopic;

// Constructor for GraduateStudent
public GraduateStudent(String name, int age, String major, String advisor, String researchTopic) {
// Call the constructor of the superclass (Student)
super(name, age, major);
this.advisor = advisor;
this.researchTopic = researchTopic;
}

// Getter method for advisor
public String getAdvisor() {
return advisor;
}

// Setter method for advisor
public void setAdvisor(String advisor) {
this.advisor = advisor;
}

// Getter method for researchTopic
public String getResearchTopic() {
return researchTopic;
}

// Setter method for researchTopic
public void setResearchTopic(String researchTopic) {
this.researchTopic = researchTopic;
}

// Method specific to graduate students
public void conductResearch() {
System.out.println(getName() + " is conducting research on " + researchTopic);
}
}

Example for accessing properties of both the superclass (Student) and the subclass (GraduateStudent).

public class Main {
public static void main(String[] args) {
// Creating a GraduateStudent object
GraduateStudent gradStudent = new GraduateStudent("John", 25, "Computer Science", "Dr. Smith", "Machine Learning");

// Accessing properties of the superclass (Student)
System.out.println("Student Information:");
System.out.println("Name: " + gradStudent.getName());
System.out.println("Age: " + gradStudent.getAge());
System.out.println("Major: " + gradStudent.getMajor());

// Accessing properties of the subclass (GraduateStudent)
System.out.println("\nGraduate Student Information:");
System.out.println("Advisor: " + gradStudent.getAdvisor());
System.out.println("Research Topic: " + gradStudent.getResearchTopic());

// Calling methods of the superclass
System.out.println("\nDisplaying student information:");
gradStudent.displayInfo(); // Calling superclass method

// Calling methods of the subclass
System.out.println("\nConducting research:");
gradStudent.conductResearch(); // Calling subclass method
}
}

Types of inheritance

  1. Single inheritance
  • In single inheritance, a subclass inherits from only one superclass.
  • Java supports single inheritance, where a class can have only one direct superclass.
  • Example: Class Dog inherits from class Animal.
  1. Multilevel Inheritance
  • In multilevel inheritance, a subclass inherits from another subclass, forming a chain of inheritance.
  • Each subclass in the chain inherits properties and behaviors from its immediate superclass.
  • Example: Class GrandChild inherits from class Child, which in turn inherits from class Parent.
  1. Hierarchical Inheritance
  • In hierarchical inheritance, multiple subclasses inherit from a single superclass.
  • Each subclass shares common properties and behaviors inherited from the same superclass.
  • Example: Classes Cat, Dog, and Rabbit all inherit from class Animal.
  1. Multiple Inheritance (Not Supported in Java)
  • Multiple inheritance allows a subclass to inherit from multiple superclasses.
  • While Java doesn’t support multiple inheritance of classes, it supports multiple inheritance of interfaces through interface implementation.
  • Example: Class Student inherits from both class Person and class Scholar.
  1. Hybrid Inheritance (Not Supported in Java)
  • Hybrid inheritance is a combination of two or more types of inheritance.
  • It can involve single, multilevel, and hierarchical inheritance, along with multiple inheritance if supported by the programming language.
  • Java doesn’t directly support hybrid inheritance due to the absence of multiple inheritance of classes.

Polymorphism

  • The word “poly” means many and “morphs” means forms, so it means many forms. we can define Java Polymorphism as the ability of a message to be displayed in more than one form. It allows us to perform a single action in different ways.
  • In Java, polymorphism is primarily achieved through method overriding and method overloading.

Compile-Time Polymorphism (Static Binding or Early Binding) — Method overloading.

  • Compile-time polymorphism occurs when the compiler determines which method or operation to execute at compile time based on the method signature (method overloading) Method overloading allows multiple methods with the same name, but different parameter lists within the same class.
public class Box {

public double calculateVolume(double sideLength) {
return sideLength * sideLength * sideLength;
}

public double calculateArea(double length, double width, double height) {
return length * width * height;
}
}

Run-Time Polymorphism (Dynamic Binding or Late Binding) — Method overriding.

  • Run-time polymorphism occurs when the JVM determines which method or operation to execute at runtime based on the actual object type (method overriding). Method overriding allows a subclass to provide a specific implementation of a method that is already defined in its superclass.
class Shape {
// Method to calculate the area of a generic shape
public double calculateArea() {
return 0; // Default implementation for a generic shape
}
}

class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius; // Override to calculate the area of a circle
}
}

class Triangle extends Shape {
private double base;
private double height;

public Triangle(double base, double height) {
this.base = base;
this.height = height;
}

@Override
public double calculateArea() {
return 0.5 * base * height; // Override to calculate the area of a triangle
}
}

Abstraction

  • Abstraction involves hiding the unnecessary details while exposing only what is relevant and important.
  • In Java, abstraction is primarily achieved through abstract classes and interfaces.

Abstract classes

  • An abstract class is a class that cannot be instantiated on its own and is meant to be extended by other classes.
  • It may contain abstract methods (methods without a body) that are meant to be implemented by its subclasses.
  • Abstract classes can also have concrete (implemented) methods.
  • Abstract methods are declared in abstract classes and are meant to be implemented by concrete (non-abstract) subclasses. These methods define a contract that must be fulfilled by the subclasses.
abstract class Shape {
// Abstract method with no body
public double calculateArea();

void setColor(String color) {
// Concrete method with an implementation
System.out.println("Setting color to " + color);
}
}

class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius; // Override to calculate the area of a circle
}
}

class Triangle extends Shape {
private double base;
private double height;

public Triangle(double base, double height) {
this.base = base;
this.height = height;
}

@Override
public double calculateArea() {
return 0.5 * base * height; // Override to calculate the area of a triangle
}
}
public class Main {
public static void main(String[] args) {
// Create a Shape object
Shape shape = new Shape(); // Compilation Error: Cannot instantiate the abstract class Shape

// Create a Circle object
Shape circle = new Circle(5.0);

// Calculate and print the area of the circle
double circleArea = circle.calculateArea();
System.out.println("Area of the circle: " + circleArea);

// Set the color of the circle
circle.setColor("Red");
}
}

Interfaces

  • An interface is a pure form (100%) of abstraction in Java.
  • It defines a contract by specifying a set of abstract methods that implementing classes must provide.
  • Classes can implement multiple interfaces, allowing for a high level of abstraction and flexibility in code design.
  • Interfaces cannot have implemented/concreate methods.
  • To implement an interface we use the keyword “implements” with class.
  • In Java, you cannot directly create an object of an interface because interfaces cannot be instantiated.
// Shape interface
public interface Shape {
// Abstract method to calculate the area
double calculateArea();

}

// Circle class implementing the Shape interface
public class Circle implements Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

// Triangle class implementing the Shape interface
public class Triangle implements Shape {
private double base;
private double height;

public Triangle(double base, double height) {
this.base = base;
this.height = height;
}

@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
`public class Main {
public static void main(String[] args) {
// Create a Circle object
Shape circle = new Circle(5.0);

// Calculate and print the area of the circle
double circleArea = circle.calculateArea();
System.out.println("Area of the circle: " + circleArea);

// Create a Triangle object
Shape triangle = new Triangle(4.0, 3.0);

// Calculate and print the area of the triangle
double triangleArea = triangle.calculateArea();
System.out.println("Area of the triangle: " + triangleArea);
}
}
  • Abstract classes and Interfaces are used to define a generic template for other classes to follow. They define a set of rules and guidelines that their subclasses must follow. By providing an abstract class, we can ensure that the classes that extend it have a consistent structure and behavior. This makes the code more organized and easier to maintain.

Up casting and Down casting in Abstraction

  • Upcasting is the typecasting of a child object to a parent object. Upcasting can be done implicitly. We can only access methods and fields defined in the parent interface/class through this reference.
  • Downcasting means the typecasting of a parent object to a child object. Downcasting cannot be implicit. We have direct access to all methods and fields defined in the sub class, in addition to any inherited methods or fields from its superclass or implemented interfaces.
// Shape interface
public interface Shape {
// Abstract method to calculate the area
double calculateArea();

}

// Circle class implementing the Shape interface
public class Circle implements Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

// getter method for radius
public double getRadius() {
return this.radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

public class Main {
public static void main(String[] args) {
// Implicit Upcasting
Shape circle = new Circle(5.0);

// We have access to calculate area, since it is overridden from the Shape interface (Parent)
double circleArea = circle.calculateArea();
System.out.println("Area of the circle: " + circleArea);

// compilation error, because it's not part of the Shape interface (Parent)
System.out.println(circle.getRadius());

// Implicit down casting: compilation error
Circle circle2 = new Shape();

// Explicit down casting
Circle circle2 = (Circle) circle;

// We have access, because the reference variable circle2 is of type sub class, which has this method
System.out.println(circle2.getRadius());

}
}

Conclusion

  • In conclusion, Object-Oriented Programming (OOP) is a powerful paradigm that promotes code organization, reusability, and maintainability by modeling real-world entities as objects with properties and behaviors. Throughout this article, we’ve explored the four main concepts of OOP: encapsulation, inheritance, polymorphism, and abstraction, and how they can be applied in various scenarios to improve software design and development.

  • Encapsulation allows us to hide the internal details of an object and expose only the necessary functionalities through well-defined interfaces, enhancing security and modularity.

  • Inheritance facilitates code reuse by allowing classes to inherit properties and behaviors from parent classes, promoting a hierarchical structure and facilitating the creation of more specialized subclasses.

  • Polymorphism allows us to perform a single action in different ways.

  • Abstraction simplifies complex systems by focusing on essential features and hiding implementation details, promoting clarity and maintainability.

Mastering SOLID principles in Java

· 10 min read

SOLID principles are one of the object-oriented approaches used in software development, intended to create quality software. The broad goal of the SOLID principles is to reduce dependencies, so that developers can change one area of the software without affecting others. Furthermore, they are intended to make designs easier to understand, maintain, reuse, and extend.

1. Single responsibility principle (SRP)

  • SRP states that, a class should have only one reason to change, meaning it should have a single responsibility.
  • This principle encourages you to create classes that do one thing and do it well.
  • Lots of responsibilities make the class highly coupled, harder to maintain and harder to understand.
  • For an example, consider the BankAccount class below:
public class BankAccount {
private double balance;
private String accountNo;
private String accountType;

//constructor
public BankAccount(double balance, String accountNo, String accountType) {
this.balance = balance;
this.accountNo = accountNo;
this.accountType = accountType;
}

public void deposit() {
//code to deposit amount
}

public void withdraw(double amount) {
//code to withdraw amount
}

public double calculateInterest() {
//code to calculate interest
}

public void saveBankAccountDetails() {
//save account information to database
}

public void sendSmsNotification() {
//code to send SMS notification to customer
}
}
  • In the context of ‘BankAccount’ class, managing deposits, withdrawals and interest are reasonable and related responsibilities to account management. But saveBankAccountDetails and sendSmsNotification methods are not related to bank account management’s behavior. Hence this class is violating SRP. The easiest way to fix this problem is create separate classes for managing bank accounts, save information to database and send SMS notifications, so that each class having only one responsibility.
// BankAccount class will handle account related responsibilities
public class BankAccount {
private double balance;
private String accountNo;
private String accountType;
private SQLBankAccountRepository sqlBankAccountRepository;
private NotificationService notificationService;

//constructor
public BankAccount(double balance, String accountNo, String accountType, SQLBankAccountRepository sqlBankAccountRepository, NotificationService notificationService) {
this.balance = balance;
this.accountNo = accountNo;
this.accountType = accountType;
this.sqlBankAccountRepository = sqlBankAccountRepository;
this.notificationService = notificationService;
}

public void deposit() {
//code to deposit amount
}

public void withdraw(double amount) {
//code to withdraw amount
}

public double calculateInterest() {
//code to calculate interest
}
}
// SQLBankAccountRepository class will handle database related responsibilities
public class SQLBankAccountRepository {
public void saveBankAccountDetails(BankAccount bankAccount) {
//save account information to database
}
}
// NotificationService class will handle notification related responsibilities
public class NotificationService {
public void sendSmsNotification(BankAccount bankAccount) {
//code to send SMS notification to customer
}
}

2. Open closed principle (OCP)

  • OCP states that, software entities (such as classes, modules, functions, etc.) should be open for extension but closed for modification.
  • In other words, you should be able to add new functionality or behavior to a system without altering the existing code.
  • Adding a new feature to software entities by modifying it, can lead new bugs, poor readability and hard to maintain.
  • For an example consider the calculateInterest method of BankAccount class.
public class BankAccount {
private double balance;
private String accountNo;
private String accountType;
private SQLBankAccountRepository sqlBankAccountRepository;
private NotificationService notificationService;

//constructor
public BankAccount(double balance, String accountNo, String accountType, SQLBankAccountRepository sqlBankAccountRepository, NotificationService notificationService) {
this.balance = balance;
this.accountNo = accountNo;
this.accountType = accountType;
this.sqlBankAccountRepository = sqlBankAccountRepository;
this.notificationService = notificationService;
}

public void deposit() {
//code to deposit amount
}

public void withdraw() {
//code to withdraw amount
}

public double calculateInterest() {
if(this.accountType.equals(‘Savings’))
return this.balance * 0.03;

else if(this.accountType.equals(‘Checking’))
return this.balance * 0.01;

else if(this.accountType.equals(‘FixedDeposit’))
return this.balance * 0.05;
}

}
  • There is a problem with the calculateInterest method. What if there is a new account type introduced with new interest requirement, We have to add another if condition in the calculateInterest method. It violates OCP. The easiest way to fix this problem is creating a common interface for all account types and implement it for every account types.
public interface BankAccount() {
public void deposit();
public void withdraw(double amount);
public double calculateInterest();
}
public class SavingsBankAccount implements BankAccount {
// attributes and constructor
// deposit and withdraw method declarations

@Override
public double calculateInterest() {
return this.balance * 0.03;
}

}
public class CheckingBankAccount implements BankAccount {
// attributes and constructor
// deposit and withdraw method declarations

@Override
public double calculateInterest() {
return this.balance * 0.01;
}

}
public class FixedDepositBankAccount implements BankAccount {
// attributes and constructor
// deposit and withdraw method declarations

@Override
public double calculateInterest() {
return this.balance * 0.05;
}

}
  • With new implementation, we can calculate interest by implementing BankAccount without modifying underlying logic. The class is open for extension (new account classes can be added) but closed for modification (existing calculateInterest methods remain untouched).
BankAccount savingsBankAccount = new SavingsBankAccount(); 
double savingsBankAccountInterest = savingsBankAccount. calculateInterest();

BankAccount checkingBankAccount = new CheckingBankAccount();
double checkingBankAccountInterest = checkingBankAccount. calculateInterest();

BankAccount fixedDepositBankAccount = new FixedDepositBankAccount();
double fixedDepositBankAccountInterest = fixedDepositBankAccount. calculateInterest();

3. Liskov Substitution Principle (LSP)

  • LSP states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program.
  • In other words, if a class is a subclass of another class, it should be able to substitute its parent class without causing problems.
  • This principle ensures that inheritance relationships are well-designed and that the derived class adheres to the contract of the base class.
  • For an example, assume that, we have a superclass A and three subclasses B, C, and D.
A obj1 = new B();

A obj2 = new C();

A obj3 = new D();
  • To ensure a valid use of LSP, all of the above 3 statements should run perfectly without interrupting the program flow.

  • Let’s take another example,

class Bird {
public void Eat() {
System.out.println("This bird can eat.");
}

public void fly() {
System.out.println("This bird can fly.");
}
}
class Parrot extends Bird {
}

class Penguin extends Bird {
@Override
public void fly() {
throw new FlyException("Penguins cannot fly");
}
}
  • The Penguin class overrides the fly() method from the base class, but the behavior is fundamentally different from what’s expected by the base class. This is an LSP violation because when we try to substitute an instance of Penguin for a generic Bird, it will not behave as a typical bird in terms of flying. This could lead to unexpected behavior in the code.

  • To resolve this LSP violation, you should restructure the class hierarchy and ensure that derived classes confirm to the contract of the base class. One way to fix this issue is to use composition or interfaces to handle behaviors that don’t fit the base class’s contract.

class Bird{
public void Eat() {
System.out.println("This bird can eat.");
}
}
class FlyingBird extends Bird{
public void fly() {
System.out.println("This bird can fly.");
}
}

class Parrot extends FlyingBird {
}

class Penguin extends Bird{
}

4. Interface segregation principle (ISP)

  • ISP states that, clients should not be forced to depend on interfaces they do not use. This principle encourages you to create specific, fine-grained interfaces rather than large, monolithic ones, to avoid forcing clients to implement methods they don’t need.
  • For an example consider the withdraw method of LoanBankAccount class that implements previously discussed BankAccount class.
public interface BankAccount() {
public void deposit();
public void withdraw(double amount);
public double calculateInterest();
}

public class SavingsBankAccount implements BankAccount {
// attributes and constructor
// deposit and calculateInterest method declarations

public void withdraw(double amount) {
if (this.balance < double amount)
this.balance-=amount;
}

}

public class CheckingBankAccount implements BankAccount {
// attributes and constructor
// deposit and withdraw method declarations

public void withdraw(double amount) {
if (this.balance < double amount)
this.balance-=amount;
}

}
public class LoanBankAccount implements BankAccount {
// attributes and constructor
// deposit and withdraw method declarations

public double withdraw() {
//empty method – cannot withdraw from loan accounts
}

}
  • Here, withdraw method in SavingsBankAccount and CheckingBankAccount classes working fine. But LoanBankAccount have a empty withdraw method, because in loan account withdrawing process not allowed. The implementation classes should use only the methods that are required. We should not force the client to use the methods that they do not want to use. That’s why the principle states that the larger interfaces split into smaller ones.
public interface BankAccount() {
public void deposit();
public double calculateInterest();
}

public interface Withdrawable() {
public void withdraw();
}

public class SavingsBankAccount implements BankAccount, Withdrawable {
//deposit, calculateInterest, withdraw methods definitions
}

public class CheckingBankAccount implements BankAccount, Withdrawable {
// deposit, calculateInterest, withdraw methods definitions
}

public class LoanBankAccount implements BankAccount {
// deposit, calculateInterest methods definitions
}
  • Here, we created BankAccount interface for deposit and calculateInterest and Withdrawable interface for withdraw. So that implementation classes can implement necessary interfaces according to its need.

5. Dependency inversion principle (DIP)

  • The principle states that we must use abstraction (abstract classes and interfaces) instead of concrete implementations. The DIP states that:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.

  • Abstractions should not depend on details. Details should depend on abstractions.

  • In simpler terms, the principle encourages you to rely on interfaces or abstract classes to decouple your code and make it easier to extend, maintain, and test.

  • Let’s understand the principle through an example.

class PDFReportGenerator {
public void generatePDFReport() {
// PDF generation logic
}
}

class HTMLReportGenerator {
public void generateHTMLReport() {
// HTML generation logic
}
}

class ReportService {
private PDFReportGenerator pdfGenerator;
private HTMLReportGenerator htmlGenerator;

public ReportService() {
pdfGenerator = new PDFReportGenerator();
htmlGenerator = new HTMLReportGenerator();
}

public void generatePDFReport() {
pdfGenerator.generatePDFReport();
}

public void generateHTMLReport() {
htmlGenerator.generateHTMLReport();
}
}
  • In the above code, ReportService directly depends on concrete implementations of report generators (PDFReportGenerator and HTMLReportGenerator). This leads to high coupling between the high-level and low-level modules. To adhere to the Dependency Inversion Principle, you should introduce abstractions (interfaces or abstract classes) and rely on those abstractions instead.
interface ReportGenerator {
void generateReport();
}

class PDFReportGenerator implements ReportGenerator {
public void generateReport() {
// PDF generation logic
}
}

class HTMLReportGenerator implements ReportGenerator {
public void generateReport() {
// HTML generation logic
}
}

class ReportService {
private ReportGenerator reportGenerator;

public ReportService(ReportGenerator generator) {
this.reportGenerator = generator;
}

public void generateReport() {
reportGenerator.generateReport();
}
}
  • In this updated code, we introduced the ReportGenerator interface, and the ReportService now depends on this abstraction rather than concrete implementations. This decouples the high-level module from low-level modules, and you can easily swap out different report generators without modifying the ReportService class.

Conclusion

  • In conclusion, the SOLID principles are a set of fundamental guidelines for designing clean, maintainable, and extensible object-oriented software. Each principle addresses a specific aspect of software design and aims to promote good design practices and robust code.

  • Single Responsibility Principle (SRP): You ensure that each class or module has only one reason to change, making your code more focused and easier to understand and maintain.

  • Open-Closed Principle (OCP): You design software components that are open for extension but closed for modification, allowing you to add new features or behaviors without changing existing code.

  • Liskov Substitution Principle (LSP): You create inheritance hierarchies where derived classes can seamlessly replace their base classes, guaranteeing that code that depends on the base class continues to work as expected.

  • Interface Segregation Principle (ISP): You define fine-grained interfaces, avoiding clients’ dependency on methods they don’t use and keeping interfaces specific to their respective contexts.

  • Dependency Inversion Principle (DIP): You rely on abstractions and decouple high-level modules from low-level modules, promoting flexibility and testability in your code.

These principles provide a strong foundation for writing high-quality, adaptable, and scalable software systems.