Intermediate Java Programming and System Design
Advanced Class Design
Designing Systems, Not Just Classes
Writing a single class is one thing. Building a real application means designing a system of interacting classes that are easy to maintain and reuse. It's like the difference between building a single brick and designing a whole house. The key is managing how these classes relate to each other, how they share data, and who is responsible for what.
This involves carefully controlling access to a class's internal state and defining clear relationships between objects. Let's move beyond basic syntax and start thinking like an architect.
Controlling Access
You already know about making fields private and methods public. This practice, called encapsulation, is the foundation of good class design. It's not just about hiding data; it's about creating a stable public for your class. The public methods are a contract that tells other developers how to interact with your object. The private implementation details can change, but as long as the contract remains the same, the rest of the application won't break.
Java provides four levels of access control to help you define this contract. Besides public and private, there are two others that offer more nuance.
| Modifier | Visibility | Common Use Case |
|---|---|---|
public | Everywhere | The public API of the class. |
protected | Same package, and subclasses | Allowing subclasses to customize behavior. |
| (default) | Same package only | Helper classes used only within a specific feature set. |
private | Same class only | Internal state and implementation details. |
Using the protected modifier is common in inheritance, allowing a subclass to access or override parts of its parent's implementation. The default (package-private) access is great for creating utility classes that only need to be visible to other classes within the same package, keeping your public interface clean.
Objects Within Objects
Classes often represent complex things that are built from smaller, simpler things. A car is not a single entity; it's a collection of parts like an engine, wheels, and seats. In object-oriented programming, we model these "has-a" relationships using composition and aggregation.
Composition is a strong "owns-a" relationship. The contained object cannot exist without its container. If the container is destroyed, the part is destroyed too.
Think of a Car and its Engine. The Engine is created when the Car is created and is an essential part of it. It doesn't make sense for the Engine to exist on its own after the Car has been junked.
// The Engine is part of the Car and managed by it.
public class Engine {
// Engine details...
}
public class Car {
// The Car creates and owns its Engine instance.
private final Engine engine;
public Car() {
this.engine = new Engine(); // Engine is created here.
}
// When a Car object is garbage collected, its Engine is too.
}
Aggregation is a weaker "has-a" relationship. The contained object can exist independently of the container. A Department in a university has Professors, but if the Department is shut down, the Professors don't cease to exist. They can be reassigned to other departments.
// Professor can exist independently.
public class Professor {
private String name;
public Professor(String name) { this.name = name; }
}
public class Department {
private List<Professor> professors;
// The Department is given existing Professor objects.
public Department(List<Professor> professors) {
this.professors = professors;
}
// When this Department object is gone, the Professor objects
// it referenced may still exist elsewhere.
}
Choosing between composition and aggregation depends on the lifecycle relationship between your objects. If one object's existence is tied directly to another, use composition. If they are just associated for a period of time, aggregation is the better fit. In a real system, understanding this relationship is key to avoiding memory leaks and managing your application's state cleanly, especially before automatic Garbage Collection kicks in.
Shared vs. Individual Data
Sometimes, a piece of data or a method should belong to the class as a whole, not to any individual object. This is the distinction between instance members and static members.
Instance members (variables and methods) belong to a specific object. Each object gets its own copy.
Static members belong to the class itself. There is only one copy, shared among all objects of that class.
Imagine a Spaceship class. Each spaceship object would have its own name and currentSpeed (instance variables). But you might want to track the total number of ships ever created. This count would be a static variable, because it's a property of the Spaceship class as a whole.
public class Spaceship {
// Instance variable: each ship has its own name.
private String name;
// Static variable: shared across all Spaceship objects.
private static int shipCount = 0;
public static final double MAX_SPEED = 299792.458; // A constant
public Spaceship(String name) {
this.name = name;
shipCount++; // Increment the shared counter.
}
// Static method: can be called without creating an object.
public static int getShipCount() {
return shipCount;
}
}
// Usage:
System.out.println("Max speed is: " + Spaceship.MAX_SPEED);
Spaceship s1 = new Spaceship("Voyager");
Spaceship s2 = new Spaceship("Discovery");
System.out.println("Ships created: " + Spaceship.getShipCount()); // Prints 2
static methods are often used for utility functions that don't depend on the state of a specific object, like the methods in the Math class (Math.sqrt(), Math.random()). Since they aren't tied to an object, you can't use the this keyword inside them or access instance variables directly.
Finally, let's look at a special tool for setup: initializer blocks. While constructors are the standard way to initialize an object, an is a chunk of code that runs every single time a new object is created, right before the constructor code is executed. It's useful for sharing setup code between multiple constructors.
import java.util.ArrayList;
class DataProcessor {
private ArrayList<String> data;
// This block runs for every constructor.
{
data = new ArrayList<>();
System.out.println("Initializer block executed.");
}
public DataProcessor() {
System.out.println("Default constructor called.");
}
public DataProcessor(String initialData) {
this(); // Calls the default constructor
data.add(initialData);
System.out.println("Parameterized constructor called.");
}
}
By combining these concepts, you can build systems that are robust, flexible, and easy for other developers to understand and use. You're no longer just coding; you're designing.
What is the primary goal of encapsulation in object-oriented design?
A University class contains a list of Student objects. If the University closes down, the Student objects still exist and can enroll elsewhere. What type of relationship is this?