In short, don't engineer your software for reusability because no end user cares if your functions can be reused. Instead, engineer for design comprehensibility -- is my code easy for someone else or my future forgetful self to understand? -- and design flexibility -- when I inevitably have to fix bugs, add features, or otherwise modify functionality, how much will my code resist the changes? The only thing your customer cares about is how quickly you can respond when she reports a bug or asks for a change. Asking these questions about your design incidentally tends to result in code that is reusable, but this approach keeps you focused on avoiding the real problems you will face over the life of that code so you can better serve the end user rather than pursuing lofty, impractical "engineering" ideals to please the neck-beards.
For something as simple as the example you provided, your initial implementation is fine because of how small it is, but this straightforward design will become hard to understand and brittle if you try to jam too much functional flexibility (as opposed to design flexibility) into one procedure. Below is my explanation of my preferred approach to designing complex systems for comprehensibility and flexibility which I hope will demonstrate what I mean by them. I would not employ this strategy for something that could be written in fewer than 20 lines in a single procedure because something so small already meets my criteria for comprehensibility and flexibility as it is.
Objects, not Procedures
Rather than using classes like old-school modules with a bunch of routines you call to execute the things your software should do, consider modeling the domain as objects which interact and cooperate to accomplish the task at hand. Methods in an Object-Oriented paradigm were originally created to be signals between objects so that Object1
could tell Object2
to do its thing, whatever that is, and possibly receive a return signal. This is because the Object-Oriented paradigm is inherently about modeling your domain objects and their interactions rather than a fancy way to organize the same old functions and procedures of the Imperative paradigm. In the case of the void destroyBaghdad
example, instead of trying to write a context-less generic method to handle the destruction of Baghdad or any other thing (which could quickly grow complex, hard to understand, and brittle), every thing that can be destroyed should be responsible for understanding how to destroy itself. For example, you have an interface that describes the behavior of things that can be destroyed:
interface Destroyable {
void destroy();
}
Then you have a city which implements this interface:
class City implements Destroyable {
@Override
public void destroy() {
...code that destroys the city
}
}
Nothing that calls for the destruction of an instance of City
will ever care how that happens, so there is no reason for that code to exist anywhere outside of City::destroy
, and indeed, intimate knowledge of the inner workings of City
outside of itself would be tight coupling which reduces felxibility since you have to consider those outside elements should you ever need to modify the behavior of City
. This is the true purpose behind encapsulation. Think of it like every object has its own API which should enable you to do anything you need to with it so you can let it worry about fulfilling your requests.
Delegation, not "Control"
Now, whether your implementing class is City
or Baghdad
depends on how generic the process of destroying the city turns out to be. In all probability, a City
will be composed of smaller pieces that will need to be destroyed individually to accomplish the total destruction of the city, so in that case, each of those pieces would also implement Destroyable
, and they would each be instructed by the City
to destroy themselves in the same way someone from outside requested the City
to destroy itself.
interface Part extends Destroyable {
...part-specific methods
}
class Building implements Part {
...part-specific methods
@Override
public void destroy() {
...code to destroy a building
}
}
class Street implements Part {
...part-specific methods
@Override
public void destroy() {
...code to destroy a building
}
}
class City implements Destroyable {
public List<Part> parts() {...}
@Override
public void destroy() {
parts().forEach(Destroyable::destroy);
}
}
If you want to get really crazy and implement the idea of a Bomb
that is dropped on a location and destroys everything within a certain radius, it might look something like this:
class Bomb {
private final Integer radius;
public Bomb(final Integer radius) {
this.radius = radius;
}
public void drop(final Grid grid, final Coordinate target) {
new ObjectsByRadius(
grid,
target,
this.radius
).forEach(Destroyable::destroy);
}
}
ObjectsByRadius
represents a set of objects that is calculated for the Bomb
from the inputs because the Bomb
does not care how that calculation is made so long as it can work with the objects. This is reusable incidentally, but the main goal is to isolate the calculation from the processes of dropping the Bomb
and destroying the objects so you can comprehend each piece and how they fit together and change the behavior of an individual piece without having to reshape the entire algorithm.
Interactions, not Algorithms
Instead of trying to guess at the right number of parameters for a complex algorithm, it makes more sense to model the process as a set of interacting objects, each with extremely narrow roles, since it will give you the ability to model the complexity of your process through the interactions between these well-defined, easy to comprehend, and nearly unchanging objects. When done correctly, this makes even some of the most complex modifications as trivial as implementing an interface or two and reworking which objects are instantiated in your main()
method.
I'd give you something to your original example, but I honestly can't figure out what it means to "print... Day Light Savings." What I can say about that category of problem is that any time you are performing a calculation, the result of which could be formatted a number of ways, my preferred way to break that down is like this:
interface Result {
String print();
}
class Caclulation {
private final Parameter paramater1;
private final Parameter parameter2;
public Calculation(final Parameter parameter1, final Parameter parameter2) {
this.parameter1 = parameter1;
this.parameter2 = parameter2;
}
public Result calculate() {
...calculate the result
}
}
class FormattedResult {
private final Result result;
public FormattedResult(final Result result) {
this.result = result;
}
@Override
public String print() {
...interact with this.result to format it and return the formatted String
}
}
Since your example uses classes from the Java library which don't support this design, you could just use the API of ZonedDateTime
directly. The idea here is that each calculation is encapsulated within its own object. It makes no assumptions about how many times it should run or how it should format the result. It is exclusively concerned with performing the simplest form of the calculation. This makes it both easy to understand and flexible to change. Likewise, the Result
is exclusively concerned with encapsulating the result of the calculation, and the FormattedResult
is exclusively concerned with interacting with the Result
to format it according to the rules we define. In this way, we can find the perfect number of arguments for each of our methods since they each have a well-defined task. It's also much simpler to modify moving forward so long as the interfaces don't change (which they aren't as likely to do if you've properly minimized the responsibilities of your objects). Our main()
method might look like this:
class App {
public static void main(String[] args) {
final List<Set<Paramater>> parameters = ...instantiated from args
parameters.forEach(set -> {
System.out.println(
new FormattedResult(
new Calculation(
set.get(0),
set.get(1)
).calculate()
).print()
);
});
}
}
As a matter of fact, Object-Oriented Programming was invented specifically as a solution to the complexity/flexibility problem of the Imperative paradigm because there is just no good answer (that everyone can agree on or arrive at independently, anyhow) to how to optimally specify Imperative functions and procedures within the idiom.
destroyCity(target)
is way more unethical thandestroyBagdad()
! What kind of monster writes a program to wipe out a city, let alone any city in the world? What if the system was compromised?! Also, what does time/resource management (effort invested) have to do with ethics? As long as the verbal/written contract was completed as agreed upon.