[Java] OOP Basics — Class, Constructor, and 4 fundamental principles

PHIL
11 min readSep 29, 2023

Object-Oriented Programming (OOP) is a programming paradigm that uses objects to model and manipulate real-world entities and their interactions in software development. In Java, one of the most popular OOP languages, everything is treated as an object.

Class and Constructor

In Java, a class is a blueprint or template for creating objects. It defines the structure and behavior that objects created from that class will have. As for constructor, it’s a special type of method in Java that is used to initialize objects when they are created. It has the same name as the class and doesn’t have a return type, not even void. Constructors are called automatically when an object is instantiated (created) using the new keyword.

Let’s create a simple example to explain the concept.

public class Person {
private String name;
private int age;

// Constructor with parameters
public Person(String name, int age) {
this.name = name;
this.age = age;
}

// Getter methods for name and age
public String getName() {
return name;
}

public int getAge() {
return age;
}
}

In this example:

We have a Person class with two private instance variables, name and age, which represent the attributes of a person.

We define a constructor for the Person class. This constructor takes two parameters: a String for the person’s name and an int for their age. Inside the constructor, we initialize the name and age instance variables with the values provided as arguments.

We also provide getter methods (getName() and getAge()) to allow external code to access the name and age attributes, as they are marked as private and not directly accessible from outside the class.

Now, let’s see how we can use this Person class to create Person objects and initialize them using the constructor:

public class Main {
public static void main(String[] args) {
// Create a Person object using the constructor
Person person1 = new Person("Alice", 30);

// Access the attributes using getter methods
System.out.println("Name: " + person1.getName());
System.out.println("Age: " + person1.getAge());

// Create another Person object
Person person2 = new Person("Bob", 25);

// Access the attributes of the second person
System.out.println("Name: " + person2.getName());
System.out.println("Age: " + person2.getAge());
}
}

Let’s explore the four fundamental principles in Java.

Encapsulation

Encapsulation is the concept of bundling data (attributes or fields) and methods (functions) that operate on that data into a single unit called a class. The data is typically declared as private to restrict direct access from outside the class.

Access to the data is restricted to methods within the class, which allow controlled modification and retrieval of the data. This helps maintain the integrity of the object’s state.

public class Person {
// Private instance variables (encapsulation)
private String name;
private int age;

// Public constructor
public Person(String name, int age) {
this.name = name;
this.age = age;
}

// Public getter method for name (accessor)
public String getName() {
return name;
}

// Public setter method for name (mutator)
public void setName(String name) {
this.name = name;
}

// Public getter method for age (accessor)
public int getAge() {
return age;
}

// Public setter method for age (mutator)
public void setAge(int age) {
if (age >= 0) { // Validation to ensure age is non-negative
this.age = age;
}
}
}

In this Person class:

  1. We define two private instance variables, name and age. These are encapsulated because they are marked as private, meaning they can only be accessed and modified from within the Person class.
  2. We provide public getter methods (getName() and getAge()) to access the values of name and age. These methods are often called "accessors."
  3. We provide public setter methods (setName() and setAge()) to modify the values of name and age. These methods are often called "mutators." The setAge() method includes validation to ensure that the age is non-negative.

Inheritance

Inheritance allows one class to inherit the properties and behaviors of another class. Subclasses can extend or override the functionality of their superclass. Inheritance enables the reuse of code and the establishment of a hierarchy of classes.

// Superclass: Vehicle
class Vehicle {
private String brand;
private int year;

// Constructor for Vehicle
public Vehicle(String brand, int year) {
this.brand = brand;
this.year = year;
}

// Getter methods for brand and year
public String getBrand() {
return brand;
}

public int getYear() {
return year;
}

// Common method for all vehicles
public void start() {
System.out.println("Starting the vehicle.");
}
}

// Subclass: Car (inherits from Vehicle)
class Car extends Vehicle {
private int numberOfDoors;

// Constructor for Car
public Car(String brand, int year, int numberOfDoors) {
super(brand, year); // Call the superclass constructor
this.numberOfDoors = numberOfDoors;
}

// Getter method for numberOfDoors
public int getNumberOfDoors() {
return numberOfDoors;
}

// Car-specific method
public void accelerate() {
System.out.println("Accelerating the car.");
}
}

// Subclass: Bicycle (inherits from Vehicle)
class Bicycle extends Vehicle {
private int numberOfGears;

// Constructor for Bicycle
public Bicycle(String brand, int year, int numberOfGears) {
super(brand, year); // Call the superclass constructor
this.numberOfGears = numberOfGears;
}

// Getter method for numberOfGears
public int getNumberOfGears() {
return numberOfGears;
}

// Bicycle-specific method
public void pedal() {
System.out.println("Pedaling the bicycle.");
}
}

public class Main {
public static void main(String[] args) {
// Create Car and Bicycle objects
Car car = new Car("Toyota", 2023, 4);
Bicycle bicycle = new Bicycle("Trek", 2023, 21);

// Access superclass properties and methods
System.out.println("Car Brand: " + car.getBrand());
System.out.println("Car Year: " + car.getYear());
System.out.println("Car Doors: " + car.getNumberOfDoors());
car.start(); // Calls the superclass method

System.out.println("Bicycle Brand: " + bicycle.getBrand());
System.out.println("Bicycle Year: " + bicycle.getYear());
System.out.println("Bicycle Gears: " + bicycle.getNumberOfGears());
bicycle.start(); // Calls the superclass method

// Access subclass-specific methods
car.accelerate(); // Calls the Car-specific method
bicycle.pedal(); // Calls the Bicycle-specific method
}
}

In this example:

  1. Vehicle is the superclass, which contains common attributes (brand and year) and behaviors (start()) shared by all vehicles. It also has a constructor to initialize these attributes.
  2. Car and Bicycle are subclasses of Vehicle. They inherit the attributes and behaviors from the superclass and add their own unique attributes (numberOfDoors for Car and numberOfGears for Bicycle) and methods (accelerate() for Car and pedal() for Bicycle).
  3. In the Main class, we create instances of Car and Bicycle objects and demonstrate how inheritance works. We can access the properties and methods of the superclass for both objects, as well as the subclass-specific methods.

Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common superclass. It enables one interface or method to represent various implementations.

// Superclass: Shape
class Shape {
public double getArea() {
return 0.0;
}
}

// Subclass: Circle (inherits from Shape)
class Circle extends Shape {
private double radius;

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

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

// Subclass: Rectangle (inherits from Shape)
class Rectangle extends Shape {
private double width;
private double height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double getArea() {
return width * height;
}
}

public class Main {
public static void main(String[] args) {
// Create an array of Shape objects
Shape[] shapes = new Shape[2];
shapes[0] = new Circle(5.0);
shapes[1] = new Rectangle(4.0, 6.0);

// Calculate and display the areas of different shapes
for (Shape shape : shapes) {
System.out.println("Area: " + shape.getArea());
}
}
}

In this example:

  1. We have a Shape superclass with a getArea() method that returns 0.0. This method is meant to be overridden by its subclasses to calculate the area of specific shapes.
  2. We create two subclasses, Circle and Rectangle, that inherit from Shape. Each subclass overrides the getArea() method to provide its own implementation for calculating the area.
  3. In the Main class, we create an array of Shape objects and assign instances of Circle and Rectangle to it. This demonstrates polymorphism because objects of different classes (Circle and Rectangle) are treated as objects of the common superclass (Shape).
  4. We iterate through the array of Shape objects and call the getArea() method on each of them. At runtime, the appropriate getArea() method from the respective subclass is called based on the actual object type, thanks to polymorphism.

The key point to understand is that even though we treat all objects in the shapes array as Shape objects, the actual behavior is determined by the subclass's overridden methods. This is the essence of polymorphism, where different objects can respond to the same method call in a way that's appropriate for their specific type.

Abstraction

Abstraction is the concept of hiding complex implementation details and showing only the necessary features of an object. This is often achieved through the use of abstract classes and interfaces. Abstract classes can have abstract methods that are meant to be implemented by concrete subclasses, while interfaces define a contract for implementing classes.

// Abstract class representing a generic shape
abstract class Shape {
// Abstract method for calculating area (no implementation)
public abstract double calculateArea();

// Concrete method that can be shared by all subclasses
public void printInfo() {
System.out.println("This is a shape.");
}
}

// Concrete subclass for Circle
class Circle extends Shape {
private double radius;

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

// Implement the abstract method for calculating the area of a circle
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}

// Override the printInfo method to provide specific information
@Override
public void printInfo() {
System.out.println("This is a circle with radius " + radius);
}
}

// Concrete subclass for Rectangle
class Rectangle extends Shape {
private double width;
private double height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

// Implement the abstract method for calculating the area of a rectangle
@Override
public double calculateArea() {
return width * height;
}

// Override the printInfo method to provide specific information
@Override
public void printInfo() {
System.out.println("This is a rectangle with width " + width + " and height " + height);
}
}

public class AbstractionExample {
public static void main(String[] args) {
// Create instances of Circle and Rectangle
Circle circle = new Circle(5.0);
Rectangle rectangle = new Rectangle(4.0, 6.0);

// Call the calculateArea method on each shape
System.out.println("Area of the circle: " + circle.calculateArea());
System.out.println("Area of the rectangle: " + rectangle.calculateArea());

// Call the printInfo method on each shape
circle.printInfo();
rectangle.printInfo();
}
}
  1. We define an abstract class called Shape, which has an abstract method calculateArea() for calculating the area of a shape. The printInfo() method is a concrete method that can be shared by all subclasses.
  2. We create two concrete subclasses, Circle and Rectangle, each of which extends the Shape class. These subclasses implement the calculateArea() method to provide specific area calculation logic for circles and rectangles.
  3. In the main method, we create instances of Circle and Rectangle and call the calculateArea() method on them. This demonstrates that we can use the abstract class Shape to work with shapes generically, without needing to know the specific implementation details of each shape.
  4. We also override the printInfo() method in each subclass to provide specific information about the shape. This showcases how abstraction allows us to define a common interface (in this case, the Shape class) while allowing each subclass to provide its own implementation details.

What’s the difference between abstract classes and interfaces?

Abstract Classes:

  1. An abstract class can have both abstract and concrete methods, providing a mix of abstract and implemented behavior.
  2. Abstract classes can have instance variables (fields) in addition to methods.
  3. A class can extend only one abstract class, meaning it supports single inheritance.
  4. Abstract classes are often used when you want to provide some common functionality for related classes.
// Abstract class: Shape
abstract class Shape {
// Concrete method
public void display() {
System.out.println("This is a shape.");
}

// Abstract method for calculating area
public abstract double calculateArea();
}

// Concrete class: Circle
class Circle extends Shape {
private double radius;

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

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

// Concrete class: Rectangle
class Rectangle extends Shape {
private double width;
private double height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double calculateArea() {
return width * height;
}
}

public class Main {
public static void main(String[] args) {
Shape circle = new Circle(5.0);
Shape rectangle = new Rectangle(4.0, 6.0);

circle.display();
System.out.println("Circle Area: " + circle.calculateArea());

rectangle.display();
System.out.println("Rectangle Area: " + rectangle.calculateArea());
}
}

Interfaces:

  1. An interface defines a contract or a set of abstract methods that concrete classes must implement.
  2. Interfaces do not have any instance variables (fields), and all fields are implicitly public, static, and final.
  3. A class can implement multiple interfaces, allowing it to inherit behaviors from multiple sources.
  4. Interfaces are used to achieve multiple inheritance of type in Java.
// Interface: Drawable
interface Drawable {
void draw(); // Abstract method
}

// Concrete class: Circle
class Circle implements Drawable {
@Override
public void draw() {
System.out.println("Drawing a circle.");
}
}

// Concrete class: Rectangle
class Rectangle implements Drawable {
@Override
public void draw() {
System.out.println("Drawing a rectangle.");
}
}

public class Main {
public static void main(String[] args) {
Drawable circle = new Circle();
Drawable rectangle = new Rectangle();

circle.draw(); // Calls the Circle class's draw method
rectangle.draw(); // Calls the Rectangle class's draw method
}
}

Key Differences:

  • Interfaces provide full abstraction; all methods are abstract, and there is no shared state.
  • Abstract classes provide partial abstraction; they can have both abstract and concrete methods, along with instance variables.
  • A class can implement multiple interfaces, but it can extend only one abstract class.
  • Abstract classes are often used to provide a common base for related classes with shared functionality.
  • Interfaces are used to define a contract that multiple classes can adhere to, promoting code flexibility and multiple inheritance of type.

Choose between interfaces and abstract classes based on your design requirements. Use interfaces when you need to define a contract or when you want to enable multiple inheritance of type. Use abstract classes when you have a base class with some common functionality and you want to provide a blueprint for derived classes.

When to use interface and when to use abstract class?

Use an Interface When:

  1. Defining a Contract: Use interfaces when you want to define a contract that multiple classes must adhere to. Interfaces allow you to specify a set of methods that implementing classes must provide.
  2. Achieving Multiple Inheritance: Java supports multiple interface inheritance, which means a class can implement multiple interfaces. If you need a class to inherit behavior from multiple sources, interfaces are the way to go.
  3. Creating a Common API: When you want to create a common API for a group of related classes that may not share a common implementation, interfaces are a good choice. This promotes consistency in the API while allowing for different implementations.

Use an Abstract Class When:

  1. Sharing Code: Abstract classes are useful when you have a common base class that contains shared code or attributes among its subclasses. You can provide concrete implementations for some methods while leaving others as abstract for subclasses to implement.
  2. Partial Implementation: If you want to provide a partial implementation of a class, you can use abstract classes. Concrete methods in an abstract class can provide default behavior that can be overridden by subclasses.
  3. Extending Existing Classes: When you want to extend an existing class and add new methods or functionality, using an abstract class is a good choice. Java supports single class inheritance, so you can’t extend multiple classes, but you can implement multiple interfaces.
  4. Enforcing a Common Base: Abstract classes are effective when you want to establish a common base for a group of related classes, but you don’t necessarily require all methods to be overridden. Subclasses have the option to override abstract methods.

--

--