Home / Blog /
Code refactoring in Java: OO vs. functional approach with examples
//

Code refactoring in Java: OO vs. functional approach with examples

//
Tabnine Team /
8 minutes /
April 4, 2024

What is code refactoring in Java? 

Code refactoring is a process that involves altering an existing body of code by changing its internal structure without affecting its external behavior. The main goal of refactoring is to improve the non-functional attributes of the software. In simpler terms, it is a systematic approach to cleaning up your code, making it more readable, maintainable, and efficient without altering its functionality.

The concept of code refactoring goes beyond just rewriting the code. It includes restructuring, optimization, and the removal of unnecessary elements from the code, while preserving its functionality.

There are several common reasons developers choose to refactor Java code:

  • Enhancing readability and maintainability: Code readability is about making your code understandable to other developers, and potentially, your future self. Refactoring enhances code readability by organizing and simplifying the code, making it easier to read and understand. The easier the code is to read, the easier it is to maintain.
  • Reducing technical debt: Technical debt refers to the future cost of past decisions made during software development. It could be the cost of fixing bugs that were ignored, or the cost of replacing outdated technologies. Refactoring helps in addressing and reducing this technical debt.
  • Facilitating future modifications: Refactoring is not just about improving the current state of the code; it’s also about preparing it for future changes. As requirements change and new features are added, the code needs to be able to accommodate these changes. Refactoring makes the code more flexible and modular, making it easier to extend.
  • Improving performance: Refactoring can often improve performance of existing applications. By simplifying and optimizing the code, refactoring can lead to more efficient code that performs better. For instance, refactoring can eliminate unnecessary calls to the database, reduce memory used by a method, or speed up method execution.
  • Updating from Older Java Versions to Java 11: Java 11 brought several new features and improvements, but also removed or deprecated some older features. Refactoring can help in updating your code from older versions of Java to Java 11. This can help you take advantage of the new features in the latest version of Java.

Approaches to refactoring Java code: OO vs. functional

You can approach Java refactoring using two main approaches: Object-Oriented (OO) and Functional Programming (FP). It is also possible to combine these approaches:

  • Object-Oriented (OO) approach: In OO, the focus is on designing clean class hierarchies and interactions between objects. Common refactoring techniques here include encapsulating fields, extracting classes, and replacing inheritance with delegation, among others. It is especially effective for projects that require complex state management and operations that are naturally modeled as interactions between objects.
  • Functional approach: Focuses on writing code as a series of stateless functions. Techniques often involve using higher-order functions, immutable data structures, and the Stream API. This approach can simplify data manipulation, make the code easier to test, and often results in fewer side effects.
  • Hybrid approach: Some modern Java projects adopt a hybrid approach, mixing elements from both paradigms. For example, a project may use OO principles for overall architecture but adopt functional programming techniques for data manipulation and logic that doesn’t involve side effects.

The choice between OO and FP or a mix of both should be based on factors such as project requirements, team expertise, and the nature of the problems being solved.

Refactoring Java code using an OO approach with examples 

Encapsulate fields

Encapsulation is a fundamental principle of object-oriented programming. It’s about hiding the internal state of an object and allowing access to it only through methods.

For example, consider a class Person with public fields name and age. Instead of accessing these fields directly, you can encapsulate them by making them private and providing public getter and setter methods.

Refactoring to encapsulate fields can make your code more robust and easier to maintain, as it ensures that the internal state of an object can’t be changed arbitrarily.

// Before encapsulation

public class Person {

    public String name;

    public int age;

}




// After encapsulation

public class Person {

    private String name;

    private int age;




    public String getName() {

        return name;

    }




    public void setName(String name) {

        this.name = name;

    }




    public int getAge() {

        return age;

    }




    public void setAge(int age) {

        this.age = age;

    }

}


 

Extract class

Sometimes, a class may become too large and difficult to maintain. In such cases, it may be beneficial to extract a part of that class into a new class. This is known as extract class refactoring.

For instance, if you have a class Order that handles both order details and payment details, you can extract the payment details into a new class Payment. This way, each class has a single, clear responsibility, making the code easier to understand and maintain.

// Before extracting class
public class Order {
    String orderDetails;
    String paymentDetails;
}

// After extracting class
public class Order {
    String orderDetails;
    Payment payment;
}

public class Payment {
    String paymentDetails;
}

 

Extract method

The extract method technique involves taking a part of a larger method and moving it into a new method. This can make the code more readable and easier to understand.

For example, if you have a method calculateTotal that calculates the total price of an order and applies a discount, you can extract the discount calculation into a new method calculateDiscount. This way, each method has a single, clear responsibility.

// Before Extract Method
public double calculateTotal(double price, double discount) {
    return price - (price * discount / 100);
}

// After Extract Method
public double calculateTotal(double price, double discount) {
    double discountAmount = calculateDiscount(price, discount);
    return price - discountAmount;
}

public double calculateDiscount(double price, double discount) {
    return price * discount / 100;
}

 

Pull-up method / push-down method

The pull-up method and push-down methods involve moving a method up or down the class hierarchy.

The pull-up method is used when a method is present in multiple subclasses and can be moved to the superclass to avoid duplication. The push-down method, on the other hand, is used when a method is present in a superclass but is only relevant to some of its subclasses.

These refactoring techniques can make the code more logical and easier to maintain, as they ensure that each class only contains the methods that are relevant to it.

// Before Pull Up
class Animal {}
class Dog extends Animal {
    void bark() {}
}
class Cat extends Animal {
    void bark() {}
}

// After Pull Up
class Animal {
    void bark() {}
}
class Dog extends Animal {}
class Cat extends Animal {}

 

Replace inheritance with delegation

Inheritance is a powerful feature of object-oriented programming, but it can sometimes lead to complex and inflexible code. The ‘replace inheritance with delegation’ technique involves replacing a superclass-subclass relationship with a more flexible delegate relationship.

For instance, instead of having a class Rectangle inherit from a class Shape, you can have Rectangle contain an instance of Shape and delegate the shape-related methods to this instance. This can make the code more flexible and easier to modify.

// Before
class Rectangle extends Shape {
}

// After
class Rectangle {
    private Shape shape;

    public void draw() {
        shape.draw();
    }
}

 

Refactoring Java code using a functional approach with examples 

Replace loops with stream API

The Stream API was introduced in Java 8 and provides a more declarative way to manage and manipulate data collections. It allows developers to write code that is more readable and easier to understand.

For instance, consider a simple loop that sums all the elements in a list. Using the traditional approach, we would initialize a sum variable and iterate through each element, incrementing the sum. However, using the Stream API, we can do the same task in a single line: int sum = list.stream().mapToInt(i -> i).sum();. The code is not only shorter but also more intuitive and readable.

// Before
int sum = 0;
for(int i : list) {
    sum += i;
}

// After
int sum = list.stream().mapToInt(i -> i).sum();

 

Use Lambda expressions

Lambda expressions have become an integral part of Java programming since their introduction in Java 8. They provide a clear and concise way to represent one method interface using an expression. Lambda expressions also enable us to write more functional and streamlined code in Java.

For example, consider a situation where we need to sort a list of strings. Using the traditional approach, we’d need to create an anonymous Comparator class, which can be quite verbose. With lambda expressions, we can accomplish the same task with a single line of code: Collections.sort(list, (s1, s2) -> s1.compareTo(s2));. This approach is much cleaner and easier to understand, making our code more maintainable.

// Before
Collections.sort(list, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return s1.compareTo(s2);
    }
});

// After
Collections.sort(list, (s1, s2) -> s1.compareTo(s2));

 

Immutable data structures

Immutability is a powerful concept that can significantly enhance the quality of your code. An object is considered immutable if its state cannot change after it’s created. In Java, we can achieve immutability by declaring all class fields final and only providing getter methods.

Immutable data structures are easier to reason about since they do not have complex state histories. They can also improve the robustness and reliability of your code. For example, when dealing with multi-threaded applications, using immutable data structures can help avoid synchronization issues.

// Before

public class Item {

    public String name;

}




// After

public final class Item {

    private final String name;




    public Item(String name) {

        this.name = name;

    }




    public String getName() {

        return name;

    }

}

 

Extract function

Another useful technique for refactoring code in Java is ‘extract function’. This involves breaking down complex functions into smaller, more manageable ones. This approach improves the readability and maintainability of your code, making it easier to test and debug.

For example, suppose we have a method that calculates and prints the total cost of all items in a shopping cart. We could refactor this method by extracting the calculation logic into a separate function. This separation of concerns makes our code more modular and easier to understand.

// Before
public class Item {
    public String name;
}

// After
public final class Item {
    private final String name;

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

    public String getName() {
        return name;
    }
}

 

Higher-order functions

In functional programming, higher-order functions are those that take one or more functions as parameters, or return a function as a result. Java 8 introduced support for higher-order functions, which can significantly improve the modularity and reusability of our code.

For instance, suppose we have a list of integers and we want to perform various operations on this list, such as calculating the sum, product or finding the maximum value. Instead of writing separate methods for each operation, we can write a higher-order function that takes a binary operator (a function) as a parameter and applies it to the list.

// Before
public void calculateAndPrintTotal(List<Item> cart) {
    double total = 0;
    for(Item item : cart) {
        total += item.getPrice();
    }
    System.out.println("Total: " + total);
}

// After
public double calculateTotal(List<Item> cart) {
    return cart.stream()
               .mapToDouble(Item::getPrice)
               .sum();
}

public void printTotal(double total) {
    System.out.println("Total: " + total);
}

 

Best practices for Java refactoring 

Ensure comprehensive test coverage

Before embarking on any refactoring process, it’s crucial to have comprehensive test coverage for your code. Tests provide a safety net and ensure that your refactoring does not inadvertently introduce new bugs or regressions. Additionally, tests can serve as documentation, clearly outlining the expected behavior of your code.

Make small, incremental changes

When refactoring, it’s advisable to make small, incremental changes rather than large, sweeping ones. Small changes are easier to understand and test, reducing the risk of introducing new bugs. Additionally, if a bug does arise, it’s much easier to identify and fix the problem when the changes are small and isolated.

Prefer clarity and simplicity

While it might be tempting to show off your programming prowess with clever, intricate solutions, it’s best to favor clarity when refactoring your code. Code is read far more often than it’s written, so prioritizing readability can save you and your colleagues a lot of time and frustration in the long run. Remember, the goal of refactoring is to improve the design and structure of your code, not to make it more convoluted.

Minimize external dependencies

Lastly, try to minimize your code’s dependencies on external systems or libraries when refactoring. External dependencies can make your code more fragile and harder to test. If you must rely on an external system, make sure to isolate the interaction as much as possible to make your code more robust and maintainable.

Automating code refactoring with generative AI

Tabnine accelerates and simplifies the entire software development process with AI agents that help developers create, test, fix, document, and maintain code. In addition to generating code, Tabnine can also extend and refactor existing code. 


To extend code with Tabnine, simply highlight the code you want it to consider, then ask Tabnine to add functionality through the chat window. Tabnine will generate code for you taking into account the context of project files open in your IDE. 

Refactoring code with Tabnine is even easier. Simply highlight a function and then ask Tabnine to factor the function according to your specifications in chat. Tabnine will generate suggested changes that you can apply to your code with the click of a button.