"O operador de módulo realmente serve para alguma coisa?" Lembro-me bem desse genuíno questionamento que tive durante a matéria de Algoritmos e Programação I na faculdade. Enquanto aprendia a lógica de programação, as estruturais condicionais, de repetição e até mesmo os demais operadores matemáticos soavam coerentes para resolver problemas através de código.
À primeira vista, por mais interessante que fosse o funcionamento do operador módulo, pareceu pouco prático, ou ainda, específico demais. Afinal, em qual contexto eu precisaria obter o resto da divisão inteira entre dois números? No quê isso me seria útil em algum momento?
Encontrei a resposta para essa pergunta em dois projetos pessoais: um conversor de notas musicais e um sorteador de times de futebol. Estes, apesar de possuírem domínios completamente distintos, enfrentavam o mesmo tipo de problema, onde encontrei nesse simples operador a solução.
Conversor de Notas
A ideia de um conversor de notas musicais é simples, mas de grande utilidade para qualquer músico ou instrumentista: dado um determinado conjunto de notas e a quantidade de semitons a serem deslocados, a função retornaria a transposição destas notas. Isto é extremamente útil em situações onde o músico precisa fazer uma rápida mudança das notas quando a música estiver em um tom diferente do arranjo original.
Apesar de crescerem indefinidamente para cima ou para baixo em uma escala, as notas musicais são representadas por elementos finitos. Nesse contexto, é coerente o uso de um Enum para representar esse objeto dentro do sistema.
public enum Note {
C("C","DO"),
C_SHARP("C#","DO#","Db","REb"),
D("D","RE"),
D_SHARP("D#","RE#","Eb","MIb"),
E("E","MI"),
F("F","FA"),
F_SHARP("F#","FA#","Gb","SOLb"),
G("G","SOL"),
G_SHARP("G#","SOL#","Ab","LAb"),
A("A","LA"),
A_SHARP("A#","LA#","Bb","SIb"),
B("B","SI");
private static final int NOTE_RANGE = 12;
private final String letter;
private final String symbol;
private String relativeFlatLetter;
private String relativeFlatSymbol;
Note(String letter, String symbol) {
this.letter = letter;
this.symbol = symbol;
}
Note(String letter, String symbol, String relativeFlatLetter, String relativeFlatSymbol) {
this.letter = letter;
this.symbol = symbol;
this.relativeFlatLetter = relativeFlatLetter;
this.relativeFlatSymbol = relativeFlatSymbol;
}
A lógica de como uma nota é transposta é inerente à própria natureza de uma nota musical. Portanto, objetivando tornar a classe mais coesa, decidi então encapsular nela toda a lógica utilizada para realizar a transposição. Para isso, criei o método transpose, que seria extensivamente utilizado para criar os arranjos.
public Note transpose(int semitones){}
Para validar o método, criei um teste para avaliar a transposição em casos mais básicos:
@Test
@DisplayName("Should return correct note on simple transposition")
void shouldReturnCorrectNoteBasicTranspose(){
Note note = Note.C;
Note transposedNote = note.transpose(3);
assertEquals(Note.D_SHARP,transposedNote);
assertEquals(3,transposedNote.ordinal());
}
Em um primeiro momento, a implementação para esse método parece ser bem simples: como as notas já estão ordenadas no Enum, basta somar o índice da nota em si com o valor passado na função, e ao final, retornar a nota correspondente ao índice resultante da soma:
public Note transpose(int semitones) {
int actualSemitone = this.ordinal();
int pretendedTone = actualSemitone + semitones;
return Note.values()[pretendedTone];
}
Essa implementação parece funcionar bem, mas em situações mais complexas, encontramos problemas: e se a nota requerida ultrapassar o limite máximo do Enum? E se o valor passado for maior que a quantidade total de notas? Escrevi mais dois testes para abranger esses cenários:
@Test
@DisplayName("Should return correct note on simple circular transposition")
void shouldReturnCorrectNoteCircularTranspose(){
Note note = Note.A;
Note transposedNote = note.transpose(5);
assertEquals(Note.D,transposedNote);
assertEquals(2,transposedNote.ordinal());
}
@Test
@DisplayName("Should return the correct note in more then one turn in the range of notes")
void shouldReturnCorrectNoteOnMoreThenOneTurn(){
Note note = Note.A;
Note transposedNote = note.transpose(17);
assertEquals(Note.D,transposedNote);
assertEquals(2,transposedNote.ordinal());
}
Nos casos acima, aquela simples implementação não funciona. É aí que o operador módulo entra.
Trazendo o tema para a área de Estrutura de Dados, as notas musicais funcionam de forma semelhante a um vetor circular: um conjunto de elementos fixos, em que o primeiro item se conecta ao último, trazendo a sensação de circularidade e permitindo a realização de ciclos.
O operador módulo é essencial para problemas dessa categoria, e é peça fundamental para garantir a circularidade esperada no comportamento da transposição de notas.
Após um ciclo de testes e refatorações, a versão final do algoritmo foi essa:
public Note transpose(int semitones) {
int actualSemitone = this.ordinal();
// Module to ensure semitones stay within the range of available notes
semitones = semitones % NOTE_RANGE;
int pretendedTone = actualSemitone + semitones;
int i = pretendedTone % NOTE_RANGE;
if (i < 0){
i += NOTE_RANGE;
}
return Note.values()[i];
}
Ao aplicar % NOTE_RANGE
tanto em semitones
quanto no cálculo final de i
, foi possível garantir que o índice sempre percorra o Enum (0…11) com segurança, evitando qualquer exceção de índice (Index Out of Bonds).
Sorteador de times
O sorteador de times foi outra ideia que, mesmo distante do domínio do exemplo acima, precisou do auxílio desse operador.
Dada uma lista de jogadores e um número de times a serem formados com esses jogadores, meu algoritmo retorna times equilibrados em overall médio e em quantidade de jogadores.
public class Player {
private Long id;
private String name;
private byte overall;
// another attributes and methods
}
public class Team {
private Long id;
private String numeralName;
private List<Player> players = new ArrayList<>();
// another attributes and methods
}
public class Draw {
private Long id;
private LocalDateTime date;
private List<Team> teams;
// another attributes and methods
}
Caso não seja possível atingir equilíbrio de overall médio (em casos de pequenos dados ou forte discrepância entre os jogadores), determinei um máximo de tentativas para meu sorteador embaralhar os times e retornar, ao menos, o melhor sorteio possível.
Planejei para essa implementação manter a aleatoriedade do sorteio. Para isso, o sorteador passava pelas seguintes etapas:
- Tratamento dos dados de entrada.
- Embaralhamento dos jogadores (através do método shuffle da API de Collections do Java).
- Distribuição dos jogadores entre os times, até se atingir o threshold definido ou atingir o máximo de tentativas.
E onde o operador módulo entra nessa ocasião? Ele foi especialmente útil na distribuição dos jogadores entre os times, conforme o exposto no método abaixo:
private List<Team> shuffleAndDistributePlayers(List<Player> players, int numberOfTeams) {
Collections.shuffle(players);
List<Team> teamsToSort = new ArrayList<>();
for(int i=0; i < numberOfTeams; i++){
teamsToSort.add(new Team(String.valueOf(i+1)));
}
for(int i = 0; i < players.size(); i++) {
int indexTeam = i % numberOfTeams;
teamsToSort.get(indexTeam).addPlayer(players.get(i));
}
return teamsToSort;
}
Em um primeiro momento, realizo o shuffle (embaralhamento) da lista de jogadores, instancio os times de acordo com a quantidade passada por parâmetro em numberOfTeams
, e, por último, distribuo os jogadores entre os times com base no índice do jogador na lista.
Mais uma vez, o operador módulo mostrou-se essencial. Se não o utilizasse, não conseguiria incluir corretamente o jogador no time com base no índice neste loop:
for(int i = 0; i < players.size(); i++) {
int indexTeam = i % numberOfTeams;
teamsToSort.get(indexTeam).addPlayer(players.get(i));
}
Em um exemplo hipotético, suponha que eu queira incluir nove jogadores em quatro diferentes times. Como ficaria meu indexTeam
, de acordo com o índice do jogador?
i | Operação | indexTeam |
---|---|---|
0 | 0 % 4 | 0 |
1 | 1 % 4 | 1 |
2 | 2 % 4 | 2 |
3 | 3 % 4 | 3 |
4 | 4 % 4 | 0 |
5 | 5 % 4 | 1 |
6 | 6 % 4 | 2 |
7 | 7 % 4 | 3 |
8 | 8 % 4 | 0 |
Assim, consigo percorrer toda a lista de jogadores e preencher a lista de times de forma segura.
Conclusão
Em resposta à pergunta feita no início do artigo, o operador módulo é extremamente importante em diversos cenários. Use %
sempre que precisar de ciclos circulares ou ações em passos específicos de uma iteração. Seja para transpor notas musicais, seja para formar times equilibrados, o módulo pode ser uma solução simples e elegante para diversos problemas a serem enfrentados no código!
Repositórios dos projetos mencionados neste artigo:
E você, já precisou usar esse operador para resolver algum problema de forma engenhosa? Deixe aqui nos comentários!
Top comments (0)