Writing clean code

Table of contents

Basic conventions and pieces of advice

Some Java standard guidelines:

  • Classes should be separated by one blank line and organized into packages.
  • Use one public class per file, and name the file the same as the class.
  • Methods inside a class should be separated by one blank line.
  • A line should be at most 100–120 characters long.
  • Stay consistent with your style (spaces, etc.)
  • And an extra: Always validate input and handle exceptions properly—never trust user input.

By convention, a variable named "ignored" is used to indicate a value that will be ignored and not used, e.g., an iterator in a for loop.

Use if checks for expected conditions and reserve exceptions for rare or truly unexpected errors, since raising exceptions is more time- and memory-intensive.

Magic numbers are hardcoded values in a program that are used directly without explanation or context, making the code harder to understand and maintain. These numbers typically represent constants or values with specific meaning, but their purpose is unclear without additional context. To improve code readability and maintainability, such numbers should be replaced with named constants or variables that explain their meaning.

Each function should be responsible for its own actions. A “master” object should not control or manage all functions. Instead, each function should handle obtaining the information it needs and implementing any changes itself. The control program can communicate with different categories of functions, but it does not need to know how they are implemented. Functions manage themselves, while the control program only coordinates them. Code should be organized into self-contained, independent units, each handling a specific task.

Clean coding techinques

Using custom API wrappers to isolate modules from the source code

API - Application Programming Interface, a set of rules and protocols that allows different software applications to communicate with each other. It defines the methods and data formats that programs can use to request and exchange information.

An API wrapper is a function or set of functions that encapsulate the functionality of a third-party service or module, providing a simpler or more controlled interface to interact with it. This allows developers to call specific functions from a module without dealing directly with the complexity of that module. Wrapping APIs can also help handle errors, manage data formatting, add flexibility for future changes, and adjust requests in a way that suits the application's needs. If the module needs to be replaced, only the wrapper has to change.


public class Main {
    public static void main(String[] args) {
        System.out.println(calculateCosine(1));
    }

    public static Double calculateCosine(double x) {
        try {
            double result = Math.cos(x);
            return result;
        } catch (Exception e) {
            System.out.println("An error occurred: " + e.getMessage());
            return null;
        }
    }
}
                                    

Memoization

Memoization is an optimization technique that stores the results of expensive function calls and reuses the cached result when the same inputs occur again. In the example below, the execution time of the first and second calls of a memoized function with the same argument is counted to demonstrate the performance improvement after the result is cached.


import java.util.HashMap;
import java.util.Map;

public class Main {
    private static final Map<Double, Double> memoizedValues = new HashMap<>();

    public static void main(String[] args) {
        long startTime = System.nanoTime();
        System.out.println(memoizedCos(1));
        long endTime = System.nanoTime();
        System.out.println("Execution time of the first call: " + (endTime - startTime) + " ns.");

        startTime = System.nanoTime();
        System.out.println(memoizedCos(1));
        endTime = System.nanoTime();
        System.out.println("Execution time of the first call: " + (endTime - startTime) + " ns.");
    }

    public static double memoizedCos(double x) {
        if (!memoizedValues.containsKey(x))
            memoizedValues.put(x, Math.cos(x));
        return memoizedValues.get(x);
    }
}
                                    

Unit testing

Unit testing is a software testing technique where individual units or components of a program are tested in isolation. In Java, a widely used framework for this purpose is JUnit, which supports automated testing, reusable setup logic, and structured test organization.

A unit test in Java is a class that contains methods annotated with @Test. These methods are automatically executed by the test runner to verify expected behavior.


import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TestMath {
    int add(int x, int y) {
        return x + y;
    }

    @Test 
    void testAddPositive() {
        assertEquals(5, add(2, 3));
    }

    @Test 
    void testAddZero() {
        assertEquals(0, add(0, 0));
    }

    @Test 
    void testAddNegative() {
        assertEquals(-2, add(-1, -1));
    }
}
                                    

Common assertions

  • assertEquals(a, b) - checking if a == b
  • assertNotEquals(a, b) - checking if a != b
  • assertTrue(x) - checking if x == true
  • assertFalse(x) - checking if x == false
  • assertThrows(Exception.class, () -> ...) - checking if an exception is thrown

Testing exceptions

Exceptions can be tested using assertThrows, which verifies that a specific exception is thrown when executing a block of code.


import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TestDivide {
    int divide(int a, int b) {
        return a / b;
    }

    @Test 
    void testZeroDivision() {
        assertThrows(ArithmeticException.class, () -> divide(10, 0));
    }

    @Test 
    void testValidDivision() {
        assertEquals(5, divide(10, 2));
    }
}
                                    

Using setup and cleanup methods

Methods annotated with @BeforeEach run before every test, while methods annotated with @AfterEach run after each test. They are used to prepare and clean up test data.


import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TestExample {
    int[] data;

    @BeforeEach
    void setUp() {
        data = new int[] {1, 2, 3};
    }

    @AfterEach
    void tearDown() {
        data = null;
    }

    @Test 
    void testLength() {
        assertEquals(3, data.length);
    }
}
                                    

Other types of tests

Testing type Description
Unit testing Testing individual units or components of a program.
Integration testing Testing combined parts of an application to ensure they work together.
System testing Testing the complete and integrated software system.
Acceptance testing Validating the software meets business requirements.
Performance testing Testing software performance under load and stress conditions.
Security testing Ensuring the software is protected against threats and vulnerabilities.
Regression testing Ensuring new changes do not break existing functionality.
Usability testing Evaluating how user-friendly and intuitive the software is.
Alpha testing Internal testing performed by the development team before release.
Beta testing External testing by end-users before official release.

Creating documentation

The comments created using /** differ from the ones that use /* because they are interpreted by Javadoc. It is a tool used to generate documentation in HTML format. These special comments are used by Javadoc to create the documentation of our classes. We can add tags with the @ sign. They suggest, e.g., what parameters a method takes or what it returns. We can also use HTML tags. To generate a Javadoc document in IntelliJ Idea, we go to Tools / Generate JavaDoc.... These comments are placed immediately before the definitions of functions, methods, and classes.


/**
 * The object <code>obj</code> represents an object.
 *
 * @author CPUcademy
 * @version 1.0.1
 */
public class Main {
    /**
     * @param args
     */
    public static void main(String[] args) {
        /**
         * This is the main method.
         */
    }
    
    /**
     * Returns the sum of two numbers.
     *
     * @param a the first number
     * @param b the second number
     * @return the sum of a and b
     */
    public int add(int a, int b) { return a + b; }
}