Mastering Object Interactions: Lessons from a Logistics Application
Ever found yourself wrestling with a codebase that feels like a tangled web of responsibilities, where objects make decisions they shouldn't, and simple values are hardcoded as cryptic "magic numbers"? You're not alone. Building robust and maintainable object-oriented systems often comes down to mastering interactions and encapsulating behavior correctly. Recent development activity in the "obj1unq/2024s2-tp3-cami-n-Pichu224" project, a logistics system for managing trucks and their cargo, highlighted several opportunities to enhance code clarity, reusability, and adherence to core object-oriented principles.
Clarity Through Naming and Constants
One common issue in many codebases is the presence of "magic numbers" – arbitrary numerical values embedded directly in the code without explanation. These numbers make code difficult to read, understand, and maintain. A seemingly innocuous number like 1000 might represent a truck's tare weight or a maximum capacity. The solution is to replace these with named constants or dedicated methods.
Before:
calculateTotalWeight() {
return cargo.sum(item => item.weight()) + 1000; // What is 1000?
}
After:
const TARE_WEIGHT = 1000;
calculateTotalWeight() {
return cargo.sum(item => item.weight()) + TARE_WEIGHT;
}
// Or, as a method for more dynamic contexts:
taraWeight() { return 1000; }
calculateTotalWeight() {
return cargo.sum(item => item.weight()) + this.taraWeight();
}
Similarly, methods should have clear, descriptive names. Renaming a method like doSomething() to emptyCargo() immediately communicates its purpose, improving readability.
Defensive Programming: Avoiding Nulls and Defaulting Values
Returning null when an item isn't found can lead to NullPointerException errors downstream, creating brittle code. A more robust approach involves either validating the existence of the item before proceeding or providing a sensible default value.
Before:
findHazardousItem(level) {
const item = this.items.find(i => i.hazardLevel() === level);
return item; // Could be null!
}
After (Option 1: Validation):
findHazardousItem(level) {
const item = this.items.find(i => i.hazardLevel() === level);
if (item === null) {
// Handle case where item is not found, e.g., throw error or return empty optional
return null;
}
return item;
}
After (Option 2: Default Value):
findHazardousItem(level) {
// Assuming a 'DefaultItem' exists or '0' is a valid default for certain properties
const item = this.items.find(i => i.hazardLevel() === level);
return item === null ? new DefaultItem() : item;
}
This principle also extends to initialization, where objects should be instantiated with meaningful defaults rather than null to ensure consistent state.
Enhancing Reusability with Method Composition
Duplicating logic, such as performing similar iterations or filters multiple times, clutters the codebase and increases maintenance overhead. Instead, leverage existing methods and language features to compose solutions more efficiently.
For instance, if you need to check if all items meet a certain criteria (e.g., even weight) or fall within a range, many languages provide powerful collection methods like all() or between(min, max).
Before (Redundant Check):
hasEvenWeightItems() {
let allEven = true;
for (const item of this.items) {
if (!item.weight().isEven()) {
allEven = false;
break;
}
}
return allEven;
}
After (Using Collection Methods):
// Assuming 'isEven()' is a method on the weight object
hasEvenWeightItems() {
return this.items.all(item => item.weight().isEven());
}
Similar principles apply to avoiding re-filtering data when a method already exists to achieve a similar outcome, perhaps by passing an argument to specialize its behavior.
Delegating Responsibilities for Cleaner Design
One of the cornerstones of good object-oriented design is the principle of responsibility. Objects should only be responsible for their own state and behavior, and decisions that affect other objects should be delegated to those objects. For example, a Truck shouldn't decide if it can pass a Route or if a Destination can receive its cargo. These decisions belong to the Route and Destination objects themselves.
Before (Truck makes external decisions):
class Truck {
// ...
canPassRoute(route) {
// Truck directly checks route properties
return route.maxHazardLevel() > this.cargoHazardLevel();
}
canUnloadAt(destination) {
// Truck directly checks destination capacity
return destination.capacity() >= this.totalCargoWeight();
}
}
After (Delegation of Responsibility):
class Truck {
// ...
// Truck asks the route/destination object for the decision
canPassRoute(route) {
return route.canAllow(this); // Route decides based on truck properties
}
canUnloadAt(destination) {
return destination.canAccept(this.cargo); // Destination decides based on cargo
}
}
class Route {
// ...
canAllow(truck) {
return this.maxHazardLevel > truck.cargoHazardLevel();
}
}
class Destination {
// ...
canAccept(cargo) {
return this.capacity >= cargo.totalWeight();
}
}
This approach ensures that each object acts as an expert on its own information, leading to a more modular, testable, and maintainable system.
Next Steps
By consistently applying these object-oriented principles – clarifying intent with named constants, building robust logic against nulls, reusing and composing existing methods, and delegating responsibilities appropriately – development teams can significantly improve the quality and longevity of their applications. The journey towards a cleaner, more efficient codebase is ongoing, and regular code reviews are vital for identifying and addressing these architectural nuances.
Generated with Gitvlg.com