Acabei de terminar de implementar uma versão do Conway “s Game of A vida usando Java.
Sendo apenas um estudante universitário, tenho certeza de que meu código não está nem perto da perfeição e gostaria de saber se você poderia dar uma olhada nele. O que posso melhorar? Existem maneiras mais rápidas para implementar certas áreas do meu código? Há código em excesso que eu posso eliminar? Existe uma maneira mais inteligente de implementar o Jogo da Vida de Conway?
EDITAR:
Na esperança de receber mais comentários, aqui está a teoria por trás da minha implementação:
Para referência, aqui estão os regras para o jogo da vida de Conway (retirado da wikipedia):
- Qualquer célula viva com menos de dois vizinhos vivos morre, como se fosse por subpopulação.
- Qualquer célula viva com dois ou três vizinhos vivos vivem para a próxima geração.
- Qualquer célula viva com mais de três vizinhos vivos morre, como se por ove rpopulação
- Qualquer célula morta com exatamente três vizinhos vivos torna-se uma célula viva, como se por reprodução.
Visão geral:
- A perspectiva diferente do Jogo da Vida de Conway
- Regras não faladas
- Explicação de métodos importantes (e estruturas de dados usadas)
Uma visão diferente do Jogo da Vida de Conway
Vamos primeiro imaginar o Jogo da Vida como uma grade ansn (nós também assumirá que esta grade tem coordenadas tais que o canto inferior esquerdo é denotado como (0,0) e o canto superior direito é denotado como (n, n) onde n é um número inteiro positivo). Esta grade bidimensional representa um grupo de n * n número de células. Cada bloco de grade pode ser pensado como uma célula, que não apenas armazena um valor booleano (morto ou vivo) para descrever o status da célula, mas também detalha sua localização por meio de suas coordenadas. Além disso, o estado atual de todas as células determina quais células morrerão, continuarão a viver ou nascerão na próxima geração de acordo com as regras encontradas acima.
Em uma perspectiva diferente, no entanto, o jogo de Conway da vida é muito semelhante ao jogo de caça-minas. Podemos pensar em uma célula viva como uma mina, e seus vizinhos armazenando o número de minas mais próximas a ela. Dessa forma, podemos usar facilmente as regras acima para determinar a geração futura (particularmente quais células morrerão e quais células nascerão).
E quanto às células que estão vivas atualmente, você pode perguntar? Bem, podemos facilmente representá-los como um número inteiro maior que 10, onde o lugar de um indica quantos vizinhos vivos a célula atualmente viva tem, e os lugares de dez indicam que a célula está viva.
Regras tácitas
Uma observação que me ocorreu é que o jogo da vida está preocupado apenas com as células vivas. Só as células vivas podem morrer, as células que continuam a viver têm que já estar vivas e as células só podem nascer se tiverem vizinhos vivos. Como resultado, verificar a grade inteira (complexidade de tempo: O (n ^ 2)) para determinar a geração futura de células seria um desperdício completo. Seria muito mais rápido se eu armazenasse todas as células vivas atualmente e verificasse cada célula viva junto com suas vizinhas para determinar a próxima geração (que é exatamente o que eu fiz).
Explicação de métodos importantes (e estruturas de dados usadas)
birth (): itera sobre um HashMap contendo um par de valor-chave de todas as células vivas junto com suas vizinhas. Se o par de valores-chave seguir o jogo das regras da vida acima, a chave (um valor inteiro que representa a localização de uma célula) é então colocada em uma pilha que contém a próxima geração de células vivas. Após cada iteração, o valor da grade é redefinido para 0 e o par de valores-chave é removido do HashMap.
insertAlive (): retira a pilha e insere a célula viva na grade. A inserção de uma célula viva segue a estrutura do caça-minas (os vizinhos de uma célula viva serão incrementados em 1 e a célula viva será incrementada em 10 para denotar que está viva). Todos os vizinhos e células vivas são então colocados em um HashMap para que birth () possa ser executado corretamente
printBoard () (deve ser nomeado boardToString): usa um construtor de strings para formatar a grade em uma string.
Nota : a maioria dos comentários foram retirados porque não acrescentam muito à legibilidade de o código
CellularAutomaton.java
package first; public abstract class CellularAutomaton{ public abstract String lifeCycle(); public abstract boolean rules(int num); }
GameOfLife.java
package first; import java.util.Stack; import java.util.HashMap; import java.util.Iterator; import java.util.Map; public class GameOfLife extends CellularAutomaton { int board[][]; int dim; Stack<Integer> stackCells; HashMap<Integer, Integer> hmapCells; public gameOfLife(int d, Stack<Integer> s){ board = new int[d][d]; dim = d; stackCells = s; hmapCells = new HashMap<>(); } public boolean rules(int num){ return num == 3 || num == 12 || num == 13; } private void birth() { Iterator<Map.Entry<Integer,Integer>> it=hmapCells.entrySet().iterator(); while(it.hasNext()) { Map.Entry<Integer,Integer> pair = it.next(); int key = pair.getKey(); if(rules(pair.getValue())){ stackCells.add(key); } board[key/dim][key%dim] = 0; it.remove(); } } private void insertAlive() { while(!stackCells.isEmpty()) { int cell = stackCells.pop(); int x = cell / dim; int y = cell % dim; int startX = (x <= 0) ? 0 : x - 1; int startY = (y <= 0) ? 0 : y - 1; int endX = (x >= dim - 1) ? x + 1 : x + 2; int endY = (y >= dim - 1) ? y + 1 : y + 2; for(int i = startX; i < endX; ++i) { for(int j = startY; j < endY; ++j) { hmapCells.put(i * dim + j, ++board[i][j]); } } hmapCells.put(cell, board[x][y] += 9); } } private String printBoard() { StringBuilder s = new StringBuilder(); for(int elements[] : board) { for(int element : elements) { if(element >= 10){ s.append("* "); } else { s.append(" "); } } s.append("\n"); } return s.toString(); } public String lifeCycle() { birth(); insertAlive(); return printBoard(); } }
Simulation.java
package first; import java.util.Stack; public class Simulation { public static void main(String args[]) throws InterruptedException{ int dim = 70; Stack<Integer> init = new Stack<>(); //all vals pushed to init is of the form: xPos * dim + yPos init.push(351); init.push(352); init.push(421); init.push(422); init.push(245); init.push(246); init.push(315); init.push(316); init.push(361); init.push(431); init.push(501); init.push(292); init.push(572); init.push(223); init.push(643); init.push(224); init.push(644); init.push(435); init.push(296); init.push(576); init.push(367); init.push(437); init.push(507); init.push(438); init.push(231); init.push(301); init.push(371); init.push(232); init.push(302); init.push(372); init.push(163); init.push(443); init.push(165); init.push(445); init.push(95); init.push(515); GameOfLife gOL = new GameOfLife(dim, init); while(true) { System.out.print(gOL.lifeCycle()); Thread.sleep(100); System.out.print("\033[H\033[2J"); } } }
Comentários
- Você ‘ fez digo que esta é uma implementação diferente, mas não há nada para explicar a teoria por trás de sua implementação ou os algoritmos e fórmulas relativamente obscuros que você ‘ está usando.
- Este é um exercício popular, então eu também recomendo que você dê uma olhada nas implementações que você pode encontrar online: ele ‘ é muito instrutivo. Gosto especialmente de esta essência que mostra uma implementação reativa em Java 8 (usando RxJava) – não sa no entanto, seria um bom código de produção .
Resposta
Primeiro de Afinal, acho que o algoritmo é bastante inteligente, o que, para minha humilde experiência, não é tão comum para um estudante universitário. Portanto, parabéns se você criou isso sozinho! Se você estiver procurando por implementações inteligentes, eu recomendaria aquelas funcionais, por exemplo, em Haskell ; consulte também Jogo mais curto da vida .
Agora, cuidado com a inteligência. Um bom código deve ser fácil de ler , fácil de entender . Isso nem sempre é possível ao lidar com algoritmos complexos, mas acredito que deveria ser um alvo.
jjjjjjjjjjj disse:
Observação: a maioria dos comentários foram retirados porque não adicionam muito para a legibilidade do código
O objetivo dos comentários é ajudar as pessoas a entenderem seu código (em geral, concentre-se no ” por que ” em vez de ” what “). Aqui, para ajudar as pessoas a entender, você precisava adicionar muito de texto à sua postagem. Idealmente, isso não é necessário porque o código é:
- autodocumentado,
- comentado para limpar coisas complexas / implícitas.
Por exemplo, aqui está uma rápida reescrita do seu código em uma tentativa de torná-lo mais expressivo:
GameOfLife.java
/** * Computes the next state of the automaton by using Conway"s original rules. */ public class GameOfLife extends CellularAutomaton { /** * Stores all cells in a two-dimensional matrix. The value stored is * the number of live neighbors of the cell, +10 if the cell is alive. */ private int board[][]; private int dim; /* * index(cell) = cellX * dim + cellY */ private Stack<Integer> indexesOfCellsAliveAtNextGeneration; private HashMap<Integer, Integer> cellsMaybeAliveAtNextGeneration; public GameOfLife(int d, Stack<Integer> s){ board = new int[d][d]; dim = d; indexesOfCellsAliveAtNextGeneration = s; cellsMaybeAliveAtNextGeneration = new HashMap<>(); } public String newGeneration() { populateWorldWithAliveCellsFromPreviousGeneration(); computeCellsMaybeAliveAtNextGeneration(); return boardAsString(); } private void populateWorldWithAliveCellsFromPreviousGeneration() { for (Map.Entry<Integer, Integer> cell : cellsMaybeAliveAtNextGeneration.entrySet()) { int cellIndex = cell.getKey(); int cellValue = cell.getValue(); if(willBeAlive(cellValue)){ indexesOfCellsAliveAtNextGeneration.add(cellIndex); } board[cellIndex/dim][cellIndex%dim] = 0; } } private static boolean willBeAlive(int cell){ return (!isAlive(cell) && nbOfNeighbors(cell) == 3) || (isAlive(cell) && (nbOfNeighbors(cell) == 2 || nbOfNeighbors(cell) == 3)); } private static boolean isAlive(int cell) { return cell >= 10; } private static int nbOfNeighbors(int cell) { return cell % 10; } private void computeCellsMaybeAliveAtNextGeneration() { cellsMaybeAliveAtNextGeneration.clear(); while(!indexesOfCellsAliveAtNextGeneration.isEmpty()) { int cellIndex = indexesOfCellsAliveAtNextGeneration.pop(); int cellX = cellIndex / dim; int cellY = cellIndex % dim; int topLeftNeighbourX = (cellX <= 0) ? 0 : cellX - 1; int topLeftNeighbourY = (cellY <= 0) ? 0 : cellY - 1; int bottomRightNeighbourX = (cellX >= dim - 1) ? cellX + 1 : cellX + 2; int bottomRightNeighbourY = (cellY >= dim - 1) ? cellY + 1 : cellY + 2; // Iterate through every cell"s neighbor to increate their neighbor number for(int i = topLeftNeighbourX; i < bottomRightNeighbourX; ++i) { for(int j = topLeftNeighbourY; j < bottomRightNeighbourY; ++j) { boolean isNeighbor = i != cellX || j != cellY; if (isNeighbor) { int neighborIndex = i * dim + j; cellsMaybeAliveAtNextGeneration.put(neighborIndex, incrementedNumberOfNeighbors(i, j)); } } } cellsMaybeAliveAtNextGeneration.put(cellIndex, makeAlive(cellX, cellY)); } } private int incrementedNumberOfNeighbors(int x, int y) { return ++board[x][y]; } private int makeAlive(int x, int y) { return board[x][y] += 10; } private String boardAsString() { StringBuilder s = new StringBuilder(); for(int[] cells : board) { for(int cell : cells) { if(isAlive(cell)){ s.append("* "); } else { s.append(" "); } } s.append("\n"); } return s.toString().trim(); } }
Na maioria das vezes, renomeei algumas variáveis / métodos e introduzi alguns métodos utilitários. O código é um pouco mais longo e parece mais verboso, mas é IMHO também mais fácil de entender. Ainda é muito processual (o que não é ruim por si só, especialmente para um programa tão simples), mas você pode tentar adicionar mais expressividade introduzindo novas classes como Board
ou Cell
. Você encontrará essas implementações OO no GitHub .
Seu código também pode apresentar problemas de memória com placas grandes. Na verdade, sua board[][]
variável armazena todas as células, mesmo as mortas. Com uma placa de 10000 x 10000 contendo apenas ~ 5/6 células, você desperdiçará muita memória. Uma solução é usar uma matriz esparsa (basicamente, uma conjunto contendo apenas células vivas).
Como observação, há alguns anos também tentei modelar um GoL altamente configurável em um ” puro ” OO way; meu código está no GitHub se você quiser conferir. O método que calcula a próxima geração do mundo é ImmutableGeneration :: nextGeneration ; dado um conjunto de células vivas, basicamente: 1) calcula todas as células vizinhas e 2) mantém apenas aquelas que estarão vivas. As regras que indicam se uma célula estará viva ou morta são implementadas em Rule.java .
EDITAR : pessoal opinião sobre concisão versus verbosidade quando se trata de n estou respondendo a um comentário
Em primeiro lugar, acredito que não existem respostas certas: é tudo sobre trocas e preferências pessoais. Nomear é difícil e você “encontrará muitos artigos sobre o assunto.
Existem apenas duas coisas difíceis na ciência da computação: invalidação de cache e nomenclatura
– Phil Karlton
Minha opinião é que a concisão é agradável, mas pode levar a ambigüidades. E a ambigüidade, especialmente a oculta, é uma ameaça.O primeiro exemplo que me vem à mente é misturar unidades por engano:
// Everything looks good... double pathLength = distanceFromGoal + distanceToTarget; // ... but adding units makes easy to spot bugs double pathLengthInKilometers = distanceFromGoalInMeters + distanceToTargetInMillimeters;
Isso sendo dito, nomes longos tornam o código mais difícil de ler. Eles podem ser reduzidos levando-se em consideração duas coisas:
- o contexto (por exemplo, nome do método / classe / pacote envolvente),
- o escopo (uma variável local em um método de 3 linhas pode ser adequado para um nome curto, enquanto uma função usada várias vezes em toda a base de código pode precisar de um mais longo).
Isso também é o que é aconselhado por Convenções de nomenclatura do Google .
Como uma última observação, como você sugeriu, nomes muito longos podem ser vistos como odores de código. Normalmente, o problema é uma falta de coesão (a classe / método faz muitas coisas diferentes – mais uma vez, não há métricas claras sobre isso, depende sentimento do desenvolvedor). Por exemplo, no código que propus, podemos pensar em populateWorldWithAliveCellsFromPreviousGeneration
como um método com responsabilidades: 1) computar as células que estarão vivas na próxima geração e 2) povoar o mundo. Assim, poderíamos dividi-lo em dois: populateWorldWith(aliveCellsFromPreviousGeneration())
.
Da mesma forma, poderíamos reunir os atributos cujo nome termina com ” atNextGeneration ” em uma nova Generation
classe:
public class GameOfLife extends CellularAutomaton { private Generation lastGeneration; public String newGeneration() { this.lastGeneration = lastGeneration.nextGeneration(); return this.lastGeneration.toString(); } } public class Generation { public Generation nextGeneration() { return new Generation(aliveAtNextGeneration(this.aliveCells)); } ... }
Mas dividir a lógica em muitas classes também aumentará a complexidade da arquitetura e tornará mais difícil entender o fluxo.
Como um conclusão Recomendo que você tenha em mente que qualquer pedaço de código é suscetível de ser modificado por desenvolvedores que não têm conhecimento prévio do projeto e que devem entender o que o código faz e por que faz isso para que eles possam mantê-lo ou reutilizar partes sem introduzir regressões. Não há nenhuma compensação: apenas compensações, e o que importa quando você faz uma escolha é que:
- você pode identificar a compensação,
- você entende os prós e os contras de cada alternativa e escolha uma delas conscientemente.
(mas não pressione demais: KISS e lembre-se de que o código pode ser refatorado posteriormente)
Comentários
- Muito obrigado por seu comentário. Estou tão feliz que você comentou porque eu nunca teria pensado em apenas armazenar as células vivas (isso muda muito do meu código e, na minha opinião, o torna muito melhor). Eu queria perguntar um pouco sobre sua opinião sobre o equilíbrio entre ser claro com nomes de variáveis e ser conciso. Em outras palavras, como você pode determinar quando o programa é muito prolixo? Isso significa que você tem que gastar um tempo extraordinariamente longo criando os nomes de variáveis corretos ou que há algo errado com sua lógica e design do código? Obrigado novamente.
- Editei minha resposta para compartilhar minha opinião sobre ela. É ‘ muito texto basicamente dizendo que ” não há respostas certas, ‘ s tudo sobre compensações, então pense nos prós e contras ao fazer uma escolha “.
Resposta
Tenho apenas uma pequena recomendação com relação à legibilidade. Quando você tem um método chamado printBoard
, normalmente espera que ele imprima o quadro . Um nome melhor para esse método seria boardToString
.