9 dicas para escrever código limpo e mais organizado na Unity

O termo “código limpo” ou “Clean Code” foi cunhado por Robert C. Martin em seu livro de mesmo nome. Esse termo é usado para descrever um conjunto de práticas voltadas para a qualidade do código, considerando principalmente sua legibilidade e facilidade de manutenção. No livro, Uncle Bob, como é conhecido, apresenta uma série de diretrizes que ele acredita que possam contribuir para a qualidade geral do código. Muitos seguem essas diretrizes quase religiosamente, porém, eu prefiro encará-las como sugestões valiosas e aspectos a serem observados enquanto programamos.

De qualquer forma, é indiscutível a importância de mantermos o código que escrevemos em um estado que facilite a leitura, compreensão e modificação tanto para nós mesmos quanto para outras pessoas. Com isso em mente, gostaria de compartilhar algumas dicas que aprendi e que considero fundamentais para manter a qualidade do código e preservar a nossa saúde mental.

Dado que este blog está centrado no desenvolvimento de jogos, reservei para o final algumas dicas específicas voltadas para a Unity, que é minha engine preferida. No entanto, é importante ressaltar que, essas dicas podem ser aplicadas a qualquer engine ou linguagem de programação, pois estão relacionadas à nossa forma de pensar durante a codificação.

Dicas gerais

Nomeie métodos e variáveis corretamente

Dar bons nomes para classes, métodos e variáveis é essencial para manter seu código limpo e organizado. Ao escolher um nome, seja o mais claro e descritivo possível. Essa é, provavelmente, a prática que terá o maior impacto na facilidade de leitura do código.

Já pensou em ter que modificar algum código que tem como nome de variável “str”? O que isso significa? É uma tentativa de abreviar “string”? “Strength”? Ou outra coisa completamente diferente? Embora seja possível deduzir o que essa variável representa dentro do contexto, o esforço mental e a chance de erros são grandes.

Além disso, não se preocupe com a quantidade de caracteres no nome. Hoje em dia, a maioria das IDEs e editores têm a função de autocompletar, o que torna o trabalho mais fácil. Obviamente, não é preciso exagerar, use o bom senso.

// Ruim
public int Str { get; set; }
public int WLim  { get; set; }

public void IncWLim(int amt)
{
    // Código
}

// Bom
public int Strength { get; set; }
public int WeightLimit  { get; set; }

public void IncreaseWeightLimit(int increaseAmount)
{
    // Código
}

Classes e métodos devem ter apenas uma responsabilidade

Há um amplo debate sobre o que “ter apenas uma responsabilidade” realmente significa. No livro “Clean Code”, Uncle Bob, diz que uma classe ou método realiza uma única tarefa quando ela faz apenas o que está descrito no nome. Outra abordagem que ele apresenta para avaliar se um método realiza múltiplas tarefas é analisar se existe a possibilidade de extrair outra classe ou método. Na minha compreensão, isso sugere que uma classe ou método está assumindo múltiplas responsabilidades quando provoca alterações em sistemas que não estão relacionados.

Um ótimo exemplo de uma classe que assume múltiplas responsabilidades, e do que se deve evitar, é a classe “GameManager” do meu jogo The Adventures of the Lone Bounty Hunter. Com quase mil linhas de código, essa classe é responsável por salvar e carregar o jogo, iniciar um novo mapa, manter o estado do jogo, entre outras tarefas. Como você pode imaginar, a abordagem correta é distribuir cada uma dessas funcionalidades em classes específicas.

// Exemplo de como eu poderia melhorar um trecho da classe GameManager.
// Se você comparar a versão original, essa é muito mais fácil para entender.

// Mais código...
private void ClearLevelBoss()
{
    if (levelBoss == null)
    {
        return;
    }

    Destroy(levelBoss);
    levelBoss = null;
}

private void ClearLevelEnemies() 
{
    if (levelEnemies == null)
    {
        return;
    }

    foreach (GameObject enemy in levelEnemies)
    {
        Destroy(enemy);
    }

    levelEnemies.Clear();
}

private void SpawnEnemies()
{
    ClearLevelBoss();
    ClearLevelEnemies();

    if (isNewGame)
    {
        NewLevelBoss();
        NewLevelEnemies();
    }
    else
    {
        LoadLevelBoss();
        LoadLevelEnemies(); 
    }
}

private void NewLevelBoss()
{
    GameObject bossToSpawn = levelData[currentLevel].BossPrefab[UnityEngine.Random.Range(0, levelData[currentLevel].BossPrefab.Length)];
    levelBoss = Spawn(bossToSpawn, mapGenerator.BossSpawnPoint);
}

private void NewLevelEnemies()
{
    List<Transform> enemiesSpawnPoints = mapGenerator.EnemiesSpawnPoint;

    foreach (Transform spawnPoint in enemiesSpawnPoints)
    {
        GameObject enemyToSpawn = levelData[currentLevel].EnemyPrefab[UnityEngine.Random.Range(0, levelData[currentLevel].EnemyPrefab.Length)];
        GameObject enemy = Spawn(enemyToSpawn, spawnPoint);
        levelEnemies.Add(enemy);
    }
}

private void LoadLevelBoss()
{
    GameObject bossToSpawn = levelData[currentLevel].BossPrefab[CurrentGameData.BossToSpawn];
    levelBoss = Spawn(bossToSpawn, mapGenerator.BossSpawnPoint);
}

private void LoadLevelEnemies()
{
    List<Transform> enemiesSpawnPoints = mapGenerator.EnemiesSpawnPoint;

    foreach (Transform spawnPoint in enemiesSpawnPoints)
    {
        foreach (Vector3 spawnPositionSaved in CurrentGameData.SpawnPointForEnemiesAlive)
        {
            if (spawnPoint.position == spawnPositionSaved)
            {
                GameObject enemyToSpawn = levelData[currentLevel].EnemyPrefab[UnityEngine.Random.Range(0, levelData[currentLevel].EnemyPrefab.Length)];
                GameObject enemy = Spawn(enemyToSpawn, spawnPoint);
                levelEnemies.Add(enemy);
            }
        }
    }
}

private GameObject Spawn(GameObject enemyToSpawn, Transform spawnPoint)
{
    GameObject enemy = Instantiate(enemyToSpawn, spawnPoint.position, Quaternion.identity, spawnPoint);
    AICharacter aiCharacter = enemy.GetComponent<AICharacter>();
    aiCharacter.CharacterDied += OnCharacterDeath;
    aiCharacter.CharacterTookDamage += OnEnemyTookDamage;
    aiCharacter.LootChanceModifier = LootChanceModifier;
    aiCharacter.SpawnPosition = spawnPoint.position;
    AICharacterSpawned?.Invoke(aiCharacter);
    return enemy;
}
// Mais código...

É claro que, em algum momento, será necessário que uma classe coordene todos esses sistemas. No entanto, nesse cenário, a responsabilidade dessa classe é exclusivamente invocar os métodos na sequência adequada.

Lembre-se de DRY, YAGNI, KISS

DRY, YAGNI e KISS são acrônimos diferentes que representam uma mesma ideia. Escreva somente o código necessário e da maneira mais simples possível para resolver o problema em mãos. Irei explicar melhor cada um deles em mais detalhes.

YAGNI significa “You aren’t gonna need it”, ou em português “você não vai precisar disso”. É o princípio que diz que nenhuma funcionalidade deve ser adicionada a menos que seja realmente necessária. Em outras palavras, não fique pensando em coisas que talvez, quem sabe, podem ser importantes no futuro. 

Além de aumentar o esforço inicial para se criar uma funcionalidade, é difícil prever as necessidades futuras, o que resulta em ainda mais trabalho quando o sistema tiver que ser modificado. Ou seja, faça apenas o que é preciso para resolver o problema em mãos, e modifique o sistema conforme novas necessidades surgem.

KISS significa “Keep it simple, stupid!”, ou em português  “mantenha simples, estúpido!”. A ideia por trás desse princípio é que devemos buscar as soluções mais simples para os problemas, ou seja, não devemos criar complexidade a não ser que seja realmente necessário.

Por último, DRY significa “Don’t repeat yourself”, ou em português “Não repita a si mesmo”. Esse princípio diz que não devemos escrever o mesmo código várias vezes. Ao não repetir o código que escrevemos, e concentrá-lo em apenas um lugar, nós diminuímos o trabalho para manter ele e as chances de erros.

Mas fique atento, nem sempre é preciso criar uma classe ou um método logo de cara, lembre-se “You aren’t gonna need it”. Mas uma boa sugestão, que não lembro onde vi, é de que se tivermos o mesmo código em ao menos três lugares diferentes, talvez seja uma boa hora de refatorar esse código para uma classe ou método. Mas lembre-se, nem todo código igual é repetido.

Cuidado com comentários

Comentários podem ser úteis para explicar qual a função de certos trechos de código, porém é importante ter cuidado para não exagerar. Quando eu comecei a estudar programação era comum ver em tutoriais com código como o abaixo.

// Evite escrever comentários dessa forma.

// Esta é a classe PlayerCharacter, que representa um herói jogável no jogo.
public class PlayerCharacter
{
    // O nome do jogador
    public string playerName;

    // O nível atual do jogador.
    public int playerLevel;

    // A saúde atual do jogador.
    public float health;

    // A saúde máxima do jogador. Inicializada como 100.
    public float maxHealth = 100f;

    // Os pontos de experiência acumulados pelo jogador.
    private int experiencePoints;

    /**
     * Causa dano ao jogador de acordo com o valor passado.
     * @param damageAmount A quantidade de dano a ser causada.
     */
    public void TakeDamage(float damageAmount)
    {
        health -= damageAmount;

        // Verifica se a saúde do jogador é menor ou igual a zero.
        if (health <= 0)
        {
            // Lógica adicional para lidar com a morte do jogador
        }
    }
}

À primeira vista, pode parecer que o código está bem documentado. No entanto, esse tipo de comentários não são úteis, pois geram desordem visual e mais trabalho, uma vez que deverá ser atualizado sempre que o código for modificado. O que na maioria das vezes não acontece. Uma abordagem melhor é seguir a sugestão anterior e nomear suas classes, métodos e variáveis com nomes claros e descritivos.

Outro problema comum é deixar trechos de código comentados. Isso geralmente acontece quando acreditamos que o código será útil mais tarde ou quando estamos realizando experimentações e nos esquecemos de apagar o código quando terminamos. No primeiro caso, use um sistema de controle de versão como Git, e apague o código comentado. No segundo, é melhor simplesmente removê-lo. Dificilmente você vai precisar do código novamente.

A maioria das linguagens de programação oferecem alguma forma de comentário de documentação. Essa forma de comentário permite a utilização de ferramentas para geração de páginas na web ou para fornecer sugestões em IDEs, por exemplo. Embora seja útil, é recomendável evitar a utilização desse tipo de comentário para código que será exclusivamente utilizado por você ou pela sua equipe. No entanto, é uma prática bem-vinda quando o código é utilizado por terceiros.

Por fim, use o comentário para explicar o “porquê” do código ter sido desenvolvido de determinada maneira, em vez de focar no “o que” ou “como” o código está realizando a tarefa. Pense da seguinte forma: o nome da classe, método ou variável deve deixar claro “o que” o código faz, enquanto o próprio código detalha “como” a tarefa é realizada. A etapa complementar consiste em explicar o “porquê” da decisão tomada ao escrever o código.

Mova condições para variáveis

Enquanto programamos é comum criarmos bloco “if” com diversas condições, como no exemplo abaixo.

if (currentBossHitPoints < 50 && distanceToPlayer < 20)
{
    // Faça alguma coisa
}

Embora não haja nada errado nessa abordagem, é preciso considerar: você sabe o que os valores representam? Mesmo que a finalidade tenha sido clara quando foram inicialmente criados, há uma alta probabilidade que isso seja esquecido posteriormente. Ainda que seja possível escrever um comentário esclarecendo o significado desse valor, por que não seguir as sugestões anteriores e criar uma nova variável?

if (currentBossHitPoints < specialAttackHitPointsThreshold && distanceToPlayer < specialAttackRange)
{
    // Faça alguma coisa
}

Além de atribuir nomes aos valores usados nas comparações, podemos mover toda a comparação para suas próprias variáveis. Isso permite a leitura do bloco if com a menor carga mental possível.

bool isPlayerInAttackRange = distanceToPlayer < specialAttackRange;
bool canUseSpecialAttack = currentBossHitPoints < specialAttackHitPointsThreshold;

if (canUseSpecialAttack && isPlayerInAttackRange)
{
    // Faça alguma coisa
}

Como você pode ver, com essas pequenas mudanças nosso código fica muito mais fácil de ler e entender.

Siga as regras e convenções do projeto

Existe uma infinidade de regras e convenções diferentes. Cada linguagem, framework e programador tem suas preferências. Com toda essa variedade é possível que uma mesma base de código seja escrita de maneiras diferentes. Essa diferença de padrões frequentemente resulta na geração de confusão entre os envolvidos.

Em C#, por exemplo, muitas pessoas gostam de prefixar variáveis privadas com _. Eu particularmente, não gosto, mas não teria nenhum problema em me adaptar para seguir as regras do projeto. O mesmo pode ser dito sobre as ordem em que gosto de declarar variáveis públicas e privadas, construtores, propriedades, e outros.

O que desejo destacar é que, independentemente de você estar trabalhando por conta própria ou em equipe, é fundamental ser consistente com as regras e convenções do projeto.

Dicas específicas para Unity

Nem tudo precisa herdar de MonoBehaviour

Quando começamos a trabalhar com Unity, é comum a inclinação de fazer com que todos os scripts que criamos no projeto herdem de MonoBehaviour. No entanto, é importante lembrar que estamos trabalhando no ambiente .NET/C#, o que nos permite usar classes simples sem recorrer à herança.

Ao não herdar de MonoBehaviour, criamos classes mais leves, já que não possuem toda a sobrecarga relacionada à Unity. Também não ficamos presos ao ciclo de vida dos componentes da engine. Outra vantagem é que fica mais fácil de se criar testes automatizados para classes que não herdam de MonoBehaviour, caso você tenha interesse nisso.

Use ScriptableObjects

Não se esqueça de utilizar a poderosa classe ScriptableObject. Por não precisar ser adicionado como componente a GameObjects, essa classe é significativamente mais leve em termos de consumo de memória e processamento.

Além disso, ao utilizar ScriptableObject podemos criar assets que são salvos diretamente no projeto. Dessa forma, configurações que são compartilhadas entre diversos objetos diferentes são podem ser reutilizadas com facilidade.

Use namespace

A utilização de namespaces auxilia na segregação de sistemas distintos. Contudo, a Unity, por padrão, gera novas classes sem a definição de um namespace. Essa configuração pode ser modificada nas opções do projeto.

Podemos, inclusive, avançar um passo adiante e criar assemblies definitions para uma organização ainda mais aprofundada de nosso código. Essa abordagem também proporciona a vantagem adicional de contribuir para a velocidade no processo de compilação. Caso você tenha interesse nessa etapa, eu já publiquei um tutorial sobre o que são e como usar assemblies definitions em detalhes.

Conclusão

Com essas dicas, você agora tem o conhecimento necessário para começar a aprimorar o padrão de qualidade do código que escreve. Mas não se preocupe se não conseguir aplicar todas desde já; com o tempo e a prática tudo se tornará mais fácil. O mais importante é ter consciência dessas ideias e aplicá-las sempre que possível.

Tem mais alguma dica de como manter o código limpo e organizado? Compartilhe com a gente no comentário em umas das redes sociais do blog.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *