As TorbenPutkonen notes, it's hard to judge your design without knowing what your goals are.
That said, in a practical GoL implementation (aiming at reasonable memory and CPU efficiency) your outward-facing API would typically consist primarily of the Grid class (or interface), with methods like:
public interface Grid {
boolean getState(int x, int y);
void setState(int x, int y, boolean state);
int getLiveCellCount();
Rectangle2D getGridBounds();
Rectangle2D getLivePatternBounds();
void nextCycle();
}
(You could call the getState
method something like isAlive
instead, and arguably that would be more natural if you're using booleans to represent the two possible cell states. But you might want to later change your code to support CA rules with more than two states, in which case you can't use booleans any more. Of course you could also just rename the method if and when you do that.)
The Grid implementation would then encapsulate the storage of the cell states (which could be done with a simple boolean[][]
array, although there are other, more compact and efficient ways as well) and the state updating.
Notably there is no Cell interface or class in this API. In my experience, it simply doesn't make sense as an abstraction level.
You'd naïvely think it would, since obviously the grid consists of cells, but IME that's a trap. The cells just don't have any useful role apart from the grid they belong to: they don't encapsulate any state that the grid can't more easily and efficiently store itself, they can't update their state or do anything else useful without the grid telling them about their neighbors, and they don't even really make sense as part of the external API, since there's never any practical reason to manipulate a cell except as part of a grid. All they do is get in the way.
(There's one possible exception to the above, and that's if you want to have the option of using different state update rules for different cells on the grid. But in practice there are IMO better ways to do that, which I'll mention below. Another exception is if you want to generalize your code to support cellular automata on arbitrary graphs, as suggested by Timothy Truckle, in which case the Cell objects can serve as natural representations of the graph nodes. I won't discuss that option further here, though.)
There's a more general lesson to be learned here: not everything needs to be an object. A big part of good OO design is learning to identify which conceptual entities in your problem domain actually make sense to represent as distinct objects, given the kinds of operations you will be doing with them, and which are better treated as just parts, aspects or collections of other objects.
Optionally the Grid class could also handle its own initialization, but since it's possible to initialize a Grid to an arbitrary state using only its public API, it can also make sense to move the responsibility for that out of the Grid implementation. You could even define a GridInitializer interface for it, although the natural API for it would look kind of trivial:
public interface GridInitializer {
void initialize(Grid grid);
}
A typical initialize() implementation would then get the bounds of the Grid passed to it, loop over all the x and y coordinates within those bounds, and call setState() for each of them.
In the Grid API described above, the nextCycle() method is responsible for updating the states of the cells according to the cellular automaton rule. In practice that will involve looping over all cells on the grid — or at least over all "active" cells that might need updating — and calculating their new states based on the previous state of the cell and its neighbors.
While this could all be done inside the Grid class, it could also make sense to separate the state update rule from the grid implementation into its own Rule class / interface, especially if you wanted your grids to be able to support several different CA rules.
In practice, you could handle that either by having the Grid store a reference to a Rule instance, or simply by passing the Rule you want to use as a parameter to the nextCycle() method. I actually rather like the latter idea, since it highlights the fact that the nextCycle() method is the only part of the Grid API that actually uses the update rule.
The API for the Rule, and consequently the implementation of the Grid's nextCycle() method, however, is where you'll start running into tradeoffs between simplicity, efficiency and generality. For example, if all you want to implement are Life-like cellular automata, then a simple and natural Rule API would look something like this:
public interface Rule {
boolean nextState(boolean state, int liveNeighborCount);
}
With an API like this, the Grid would be responsible for counting the neighbors of each cell, which could be good for performance since you could implement all sorts of optimizations there. And the Rule class wouldn't have to know anything at all about Grids, which keeps the relationship between those classes nice and simple.
On the other hand, an API like the above severely limits the number and variety of possible rules you can have. Maybe, for example, you'd like to support rules with different kinds of neighborhoods, or non-totalistic or even non-isotropic rules.
You could have the Rule somehow tell the Grid which neighbors of a cell it wants to be counted and how, but that can quickly get complicated and awkward. Instead, for maximum generality, you could move the responsibility for neighbor-counting from the Grid to the Rule by having the Grid pass itself to the Rule's nextState() method, like this:
public interface Rule {
boolean nextState(boolean state, int x, int y, Grid grid);
}
(Actually the state
parameter above is kind of redundant and could be omitted, since the Rule can always obtain it by calling grid.getState(x, y)
. But since the rule is likely to need it anyway, passing it as a parameter may allow some minor optimizations in nextCycle(). And it just sort of intuitively feels like it belongs in the method signature, although it's always worth questioning such intuitions.)
Of course the price you pay for this flexibility (besides limiting your options for low-level performance optimization) is that a general nextState() implementation will now look a lot more complicated, as it now needs to also handle counting the live neighbors of the cell. But you can always encapsulate this complexity e.g. in an abstract base class for Life-like rules, so that you won't need to reimplement it several times:
public abstract class LifeLikeRule implements Rule {
public abstract boolean nextState(boolean state, int liveNeighborCount);
public boolean nextState(boolean state, int x, int y, Grid grid) {
return nextState(state, countNeighbors(x, y, grid));
}
private static int countNeighbors(int x, int y, Grid grid) {
int neighbors = 0;
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
if ((dx != 0 || dy != 0) && grid.getState(x + dx, y + dy) == true) {
neighbors++;
}
}
}
return neighbors;
}
}
(Note that here I'm assuming that the Grid implementation takes care of handling boundary conditions appropriately and transparently, so that the Rule can just call getState() without having to worry about whether or not the coordinates are within bounds!)
Another useful option provided by this generalized Rule API is that it can support non-translation-invariant rules — that is, rules that are different for different cells on the grid. This could be handy if you wanted to experiment with, say, having one half of the grid follow a different update rule than the other one.
You could even define a generic adapter class that stores a Rule instance for each cell on the grid and delegates nextState() calls to those instances, e.g. something like this:
public class RuleMap implements Rule {
private Rule[][] rules;
public boolean nextState(boolean state, int x, int y, Grid grid) {
return rules[x][y].nextState(state, x, y, grid);
}
}
(Constructing and initializing this map is left as an exercise.)
And since this RuleMap is itself a Rule, you can pass it to a Grid without the Grid needing to even know that anything funny is going on!
Finally, I should note one more possible generalization you might be interested in: rules with more than two cell states. Fortunately, supporting those is easy enough — just change the types representing cell states in the API from boolean
to e.g. int
.
(In practice, to save memory, you might want to have different Grid implementations with different numbers of maximum states, and maybe a factory method to select the appropriate one at run-time depending on the rule. But that's an optimization you can implement later if you want to; for an initial implementation, just storing the states in an int[][]
is fine.)
Ps. You might also want to consider making your Grids immutable, with nextCycle() returning a new Grid instead of mutating the current one. On a modern Java system with efficient GC and memory allocation the performance penalty should be fairly small, possibly even negligible. And in exchange you get the ability to safely pass references to Grids around (e.g. to record the time evolution of the CA pattern) without having to worry about them suddenly changing under you.
Of course, if you do that, you'll probably also want to implement some form of the Builder pattern so that you can initialize new Grids and/or create modified versions of existing ones.
cell.updateStatus
is ever only called on a dead cell (new Cell(false)
). \$\endgroup\$