Estilo de código - Boas práticas de programação em linguagem C

Boas práticas de programação

No mundo dinâmico do desenvolvimento de software para sistemas embarcados, um código ou algoritmo não pode simplesmente ser considerado como correto por realizar aquilo que se deseja. Outros aspectos relacionados à legibilidade, manutenção e segurança devem ser considerados durante sua implementação.

 

Um desenvolvedor de software em geral, inclusive embarcado, idealmente deve prezar por manter seu código dentro de padrões definidos ou pelo senso comum, pelo projeto em que se está contribuindo na comunidade, ou por regras da própria empresa em que se trabalha.

 

Este artigo considera as boas práticas de programação para qualquer nível, desde desenvolvedores que estão entrando no mundo embarcado até sêniors que procuram melhorar sua performance no desenvolvimento de código.

 

Mas o que seriam estes estilos de código, para que servem e o que podemos conseguir de melhorias ao adotar boas práticas de programação?

 

Bem, de forma geral, o estilo de código pode ser considerado uma questão filosófica diretamente ligada aos gostos do desenvolvedor. Porém, tanto para desenvolvedores embarcados quanto para propósitos gerais, existem alguns estilos adotados (e comprovados de alguma forma) que podem beneficiar tanto o próprio desenvolvedor quanto outros que porventura poderão vir a utilizar o código ou algoritmo desenvolvido.

 

Iremos neste artigo exemplificar como não programar na linguagem C, ou seja, uma pequena paródia ao que se deveria ser feito em comparação a erros comuns da linguagem, citando pensamentos que o programador faz durante o desenvolvimento do código.

 

 

“Isso nunca vai acontecer”

 

Desenvolvedor se depara em situações que “nunca irão acontecer”, e, por conta disso, algumas verificações no código deixam de existir. Um exemplo famoso para este tipo de suposição aconteceu em 2014 com o tão conhecido YouTube, onde o vídeo Gangnam Style causou o overflow da variável que conta o número de visualizações de vídeo (int32_t até então). Este caso não foi tão grave, mas em uma situação crítica, suposições deste tipo devem garantir que o que não pode acontecer realmente não irá acontecer. Uma boa forma de garantir que tais suposições realmente assegurem que não possam acontecer é utilizar asserts, exemplo:

 

void write_string(char *str) {
    assert(str != NULL);
    /* codigo aqui */
}

 

É um exemplo bem simples, mas vamos imaginar que esta função seja uma função interna de uma lib, e portanto, quem a desenvolveu está garantindo que nunca vai acontecer um caso em que uma string nula será passada. Porém, aqui é o caso perfeito de se prevenir contra aquilo que “nunca irá acontecer”, portanto, o uso do assert é bem recomendado.

 

P.S.: Claro, se for uma lib que será utilizada por outros clientes, é natural existir verificações dos argumentos da função, mas não iremos entrar neste caso, pois é o caso normal de verificação de possíveis erros durante o desenvolvimento de código.

 

Pensando no próprio caso do YouTube, a suposição de que nenhum vídeo seria visualizado mais vezes do que um signed int de 32 bits pudesse armazenar, também pode ser verificado:

 

int increment_counter() {
    assert(count < LONG_MAX);
    ++count;
}

 

Outro caso simples, mas pensando em uma aplicação de um sistema crítico, onde o desenvolvedor assumiu que o nível do tanque nunca irá ultrapassar um valor limite. Esta suposição pode não ser sempre verdade, e em caso de overflow, a variável irá começar a contar do zero, e sua planta estará transbordando, mas seu sistema de controle irá dizer que o nível está baixo, ou normal.

 

Assertions são bons candidatos para se garantir que coisas que “nunca irão” acontecer realmente nunca irão acontecer.

 

 

Forçar evitar falsos positivos

 

Existem alguns casos de desenvolvimento em que estamos realizando testes unitários em nosso código e, ao forçar algum erro, temos falsos positivos. Ou seja, ao testar o retorno de uma função para algum erro, devemos garantir que estamos testando contra o erro que foi forçado e  não testando apenas contra algum erro genérico.

 

Suponha que você esteja validando a funcionalidade seu método que valida uma senha e você quer ver se forçando um erro de ponteiro inválido o método estará retornando erro. Os possíveis retornos de erro são:

 

#define ERR_TAMANHO_INVALIDO (-1)
#define ERR_PONTEIRO_INVALIDO (-2)
#define ERR_SENHA_INVALIDA (-3)

E no seu teste unitário você está testando, sem se dar conta, de que está acontecendo um possível falso positivo. Ou seja, você conseguiu forçar o erro, porém, você não está testando contra o erro específico que você está procurando, mas sim contra qualquer erro. Abaixo é exibido um exemplo simples:

 

assert(valida_senha(ponteiro_do_buffer, tamanho_buffer) < 0);

 

Se sua função possuir algum bug, e se você testar contra o erro que de fato deveria ser testado, pode ser que o erro não seja o mesmo que você espera por algum problema de implementação. Para evitar estes falsos positivos, é ideal sempre testar contra exatamente aquilo que se está esperando. Abaixo segue o exemplo correto, onde o método é testado contra todos seus possíveis códigos de erro.

 

assert(valida_senha(ponteiro_do_buffer, tamanho_buffer) == ERR_TAMANHO_INVALIDO);
assert(valida_senha(ponteiro_do_buffer, tamanho_buffer) == ERR_PONTEIRO_INVALIDO);
assert(valida_senha(ponteiro_do_buffer, tamanho_buffer) == ERR_SENHA_INVALIDA);

 

 

Funções que inicializam devem seguir o conceito de atomicidade

 

Quando criamos alguma função do tipo inicia_recurso() que inicializa algum recurso ou módulo, devemos nos preocupar nos aspectos de atomicidade. Isto é, a função inicia_recurso() deve possuir apenas dois estados: (1) executado com sucesso; (2) não executado. Ou seja, se ela falhar em sua execução, ela deve retornar ao estado em que se ela nunca tivesse sido executada/invocada. É necessário que a função internamente garanta que em caso de falha, ela desaloque qualquer recurso que tenha sido alocado durante sua execução. Assim ela retorna em um estado seguro, mesmo em caso de falha.

 

#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct ctx {
	char *nome;
	int log;
} ctx_t;

int inicia_log(char *nome) {
	int index_log;
	Index_log = cria_arquivo_log(nome);
	If (index_log <= 0) {
		syslog (LOG_INFO, "Arquivo de log nao iniciado");
	} else {
		syslog (LOG_INFO, "Sucesso ao criar arquivo de log");
	}
	return index_log;
}

bool inicia_daemon(ctx_t *ctx) {
    static const char *string = "daemon_app";
    ctx->nome = malloc(strlen(string));
    if (ctx->nome == NULL) {
        return false;
    }
    strncpy(ctx->nome, string, strlen(string));
    ctx->log = inicia_log(ctx->nome);
    if(ctx->log) {
        free(ctx->nome);
        ctx->nome = NULL;
        return false;
    }
    return true;
}

int main()
{
	ctx_t ctx;
	bool resp;
	
	resp = inicia_daemon(&ctx);
	return resp ? 0 : -1;
}

 

No exemplo acima, percebemos que, em caso de falhas intermediárias da função inicia_daemon(), ela mesmo se encarregará de desalocar todos os recursos que foram alocados até então, inclusive limpando o ponteiro que havia sido atribuído em ctx->nome. Desta forma é possível garantir que em qualquer falha de métodos inicializadores, eles serão executados atomicamente pelo seu código.

 

 

Ponto único de retorno

 

Este estilo de código requer bastante conhecimento do código para que seja seguro utilizar este estilo de programação pois, para ser efetivo, o código deve permitir o erro em cascata, isto é, a propagação de erros dentro da função não irá quebrar o código. No exemplo anterior, não poderíamos implementar este método pois a alocação de memória para armazenar o nome do daemon irá determinar se a próxima instrução pode ou não ser executada. Desta forma, aplicando o ponto único de retorno neste código iria nos gerar o tão temível segmentation fault por acesso indevido à região de memória. Em algumas situações ele é bastante útil para que possamos entender o que aconteceu de errado no fluxo de código. Abaixo segue um exemplo simples de como pode-se realizar erro em cascata tirando vantagem do ponto único de retorno:

 

#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include “valida_campos.h”  /* header das funcoes valida_(*) */

#define NO_ERROR       (0)
#define ERR_VAL_MAC    (1 << 1)
#define ERR_VAL_SERIAL (1 << 2)
#define ERR_VAL_IP     (1 << 3)

char valida_conexao(char **buff) {
	char ret_val = NO_ERROR;
	
	if(valida_mac(buff[0]) != NO_ERROR)
	    ret_val |= ERR_VAL_MAC;
	if(valida_serial(buff[1]) != NO_ERROR)
	    ret_val |= ERR_VAL_SERIAL;
	if(valida_ip(buff[2]) != NO_ERROR)
	    ret_val |= ERR_VAL_IP;
	return ret_val;
}

int main() {

	char mascara_erro = 0;
	char **valores = { "AA:BB:CC:DD:EE:FF", "123456", "127.0.0.1"};
	
	mascara_erro = valida_conexao(valores);

	verifica_erro(mascara_erro);

	return 0;
}

 

 

Utilização de GOTO nem sempre é temível

 

Muitos são totalmente contra a utilização de GOTOs nos código, porém vários artigos propõem sua utilização (inclusive muitos goto são vistos no Linux kernel) de forma correta. No próprio exemplo anterior, quando não é possível realizar o cascateamento de erro por conta de alocação de memória por exemplo, a utilização de goto é bem empregada. Vamos supor que os métodos valida_* realizam alocações de memória, e que estas alocações são dependentes entre métodos, isto é, a alocação que é realizada internamente por validate_mac irá determinar que a validate_serial possa ser executada ou não.

 

char valida_conexao(char **buff) {

	char ret_val = NO_ERROR;
	
	if(valida_mac(buff[0]) != NO_ERROR) {
		ret_val = ERR_VAL_MAC;
		goto invalid_mac;
	}
	    
	if(valida_serial(buff[1]) != NO_ERROR) {
		ret_val = ERR_VAL_SERIAL;
		goto invalid_serial;
	}
	
	if(valida_ip(buff[2]) != NO_ERROR) {
		ret_val = ERR_VAL_IP;
		goto invalid_ip;
	}
	
invalid_ip:
    free_ip(buff[2]);
invalid_serial:
    free_serial(buff[1]);
invalid_mac:
    free_mac(buff[0]);
    
    return ret_val;
}

 

Neste exemplo, vimos que os gotos seguem a ordem inversa das execuções das funções que validam os respectivos valores. Essa inversão ocorre pois, se a verificação falha no seu último passo, todos os passos anteriores devem ser desfeitos. Assim, a desalocação de memória realizada pelos passos intermediários também é desfeita, evitando vazamento de memória.

 

 

Os perigos de não se utilizar chave

 

Muitos desenvolvedores não utilizam chaves em declarações simples pois não há necessidade de proteger com chaves uma única linha de código. No Linux kernel e em muitos outros códigos open-source utilizam este paradigma de declarações com blocos de código de única linha não são protegidos com chaves. Porém, este tipo de regra é bastante perigosa para o desenvolvimento de sistemas embarcados, e aqui vão alguns exemplos que pode custar ao desenvolvedor horas de depuração, ou custar até mesmo uma nova planta.

 

int loop_principal() {
    int ret = NO_ERROR;

    if (verifica_nivel(tanque) >= VALOR_MAXIMO) // verifica nivel maximo
        dispara_alarme();

    ret |= loop_medicao();
    ret |= motor_ligado();

    return ret;
}

 

Supondo então que o desenvolvedor apagou alguma parte do comentário, e sem perceber, a linha de código dispara_alarme() acaba subindo de linha, e fazendo parte do comentário.

int loop_principal() {
    int ret = NO_ERROR;

    if (verifica_nivel(tanque) >= VALOR_MAXIMO) // verifica nivel dispara_alarme();
    ret |= loop_medicao();
    ret |= motor_ligado();

    return ret;
}

 

Este é um dos erros mais inocentes que pode acontecer, mas como visto no exemplo, pode-se nunca mais disparar o alarme e além disso, o loop_medicao() pode vir a ser executado somente quando o nível está igual ou maior que valor limite. Este é um pequeno erro, onde o desenvolvedor resolveu apagar uma pequena parte do comentário e, por causa de um descuido, a linha abaixo do comentário subiu e fez parte do próprio comentário.

 

Por mais insignificante que este descuido possa parecer, suas consequências são graves e sim, este tipo de erro pode acontecer com mais frequência do que imaginamos. Para evitar isso, procure comentar código sempre com blocos de comentários /* comentario aqui */, desta forma você está garantindo que apenas o que está dentro do bloco será seu comentário.  

 

 

Programação em única linha

 

Quando aprendemos a utilizar os operadores ternários, acabamos querendo realizar inúmeras operações em uma única linha. Parece mais eficiente visualmente falando, porém, isso afeta diretamente a legibilidade do código e, por consequência, afeta o desempenho durante alguma depuração de possível bug.

 

int adicionar_atualizar_usuario(char *nome, char flag_adiciona) {
    return (cria_usuario(nome, grupo(flag_adiciona == 'Y')) ? move_usuario() : remove_usuario());
}

 

É de se admitir que em uma única linha de código, conseguimos escrever o que se precisaria de talvez 4 ou 5 linhas. Talvez este tipo de código segue a filosofia de que quanto menos linhas de código, menos bugs, mas vamos comparar com o código abaixo, onde o mesmo código acima foi escrito, porém em linhas separadas prezando pela legibilidade.

 

int adicionar_atualizar_usuario(char *nome, char flag_adiciona) {
    int ret = 0;
    bool adiciona = (flag_adiciona == 'Y');

    ret = cria_usuario(nome, grupo(adiciona));

    adiciona ? move_usuario : remove_usuario();

    return ret;
}

 

Legibilidade é fator fundamental para o desenvolvimento, e por mais claro que as instruções dessa linha possam ser, qualquer outro desenvolvedor terá que gastar alguns minutos tentando entender o fluxo de execução desta linha.

 

 

Comentar o óbvio

 

Os famosos fall-through de switch cases são geradores de dor de cabeça para quem está lendo ou revisando algum código. Erros em switch cases são bastante comuns pelo esquecimento de breaks que acabam fazendo com que um case passe por dentro de outro, porém, algumas vezes o desenvolvedor realmente deseja isso. Contudo, se nos depararmos com esta situação, e nenhum comentário é vinculado a isso, certamente iremos achar que o desenvolvedor esqueceu de colocar o break e podemos sem querer querendo alterar toda a lógica do código. Por isso, é muito importante comentar inclusive o óbvio, para evitar problemas simples, mas que geram resultados catastróficos.

 

switch(selecao) {
	case A:
	    rotaciona_90x();
		break;
	case B:
		rotaciona_90y();
		break;
	case C:
		rotaciona_90y();
	default:
		rotaciona_90z();
}

 

Olhando o código acima, podemos pensar que o desenvolvedor esqueceu de colocar o break no case C. Então, decidimos colocar o break que está faltando. Isso irá evitar que o rotaciona_90z() seja executado, e o braço do robô irá agir de maneira errada. Ou seja, por interpretação errada de outro desenvolvedor, agora o sistema está com bug. Para evitar isso, um simples comentário bastaria para evidenciar a necessidade do fall-through.

 

case C:
		rotaciona_90y();
		/* Fall-through: precisamos rotacionar o eixo Z */
	default:
		rotaciona_90z();

 

 

Não economize tempo ao documentar código

 

Quando se desenvolve código em empresa ou para a comunidade, é importante zelar pela documentação do código para que outros possam entender o que ele realiza sem precisar de grande conhecimento de suas entranhas. Ser claro ao documentar é tão importante quanto se preocupar em desenvolver um código limpo e eficiente, portanto seja explícito quanto aos parâmetros de entrada e saída de sua função/rotina, etc. Se sua função requer ponteiros, comente também se os ponteiros são desalocados em caso de falha, entre outros recursos que a função possa alocar.

 

/**
 * \brief Valida se o nivel esta de acordo
 * \param endereco_tanque Ponteiro para o tanque desejado
 * \param parametros Parametros do tanque solicitado
 * \param nivel Nivel maximo permitido
 * \return Retorna TRUE em caso de nivel OK.
 *         Retorna FALSE caso o nivel esteja acima
 */
bool validar_nivel(char *endereco_tanque, char **parametros, int nivel);

 

Neste exemplo acima, a documentação não diz qualquer informação sobre como os ponteiros são tratados. Fica assim a cargo do desenvolvedor de analisar o código (se possível), ou ir na tentativa e erro para descobrir se os ponteiros devem ser desalocados externamente ou não.

Agora, um exemplo com uma boa documentação é mostrada:  

 

/**
 * \brief Valida se o nivel esta de acordo
 * \param endereco_tanque Ponteiro para o tanque desejado [IN]
 * \param parametros Parametros do tanque solicitado [IN][OUT]
 * \param nivel Nivel maximo permitido
 * \return Retorna TRUE em caso de nivel OK, e as informacoes do tanque sao
 * 		 		armazenadas no ponteiro "parametros".
 *         Retorna FALSE caso o nivel esteja acima do especificado.
 * 
 * \info A memoria alocada "por parametros" deve ser desalocada pelo usuario.
 */
bool validar_nivel(char *endereco_tanque, char **parametros, int nivel);

 

Assim, um código bem documentado possui muito mais valor. Embora seja preciso dedicar tempo para documentação, o próprio desenvolvedor se beneficia de seus comentários, não precisando ele mesmo retornar ao código para lembrar seu funcionamento interno.

  

 

(EXTRA) Precauções em código seguro

 

Uma questão importante relacionado ao estilo de código é buscar por código seguro. Ao utilizarmos funções da stdlib por exemplo, podemos verificar as maneiras corretas e seguras de utilizá-las. Existem comunidades especializadas em investigar e definir padrões de programação que garantem a segurança do código.

 

Um website para consulta de exemplos de código seguro é encontrado em aqui. O CERT Secure Coding Standards é formado por grupos de desenvolvedores que tentam explorar vulnerabilidades da linguagem C e C++, dando exemplos reais e soluções “ideais” para serem utilizadas. Portanto, sempre que formos utilizar alguma função conhecida, como atoi(), é extremamente recomendável procurar no CERT sobre ela e ver o que eles têm a dizer, quais são suas vulnerabilidades e o que pode se utilizar em sua substituição.

 

O padrão MISRA C define um conjunto de regras para a linguagem de programação em C que visa garantir padrões seguros de desenvolvimento. Este padrão foi desenvolvido para o desenvolvimento de sistemas embarcados para automóveis, porém ganhou popularidade e atualmente é adotado nas mais diversas áreas de desenvolvimento por conta de seus benefícios.

 

Existem inúmeros outros lugares e comunidades que procuram exemplificar e apresentar soluções de situações reais em que códigos podem ser melhorados tanto em questão de performance quanto em questão de segurança. Estar atento a estes exemplos torna-se bastante útil para que o desenvolvedor desenvolva melhor sua capacidade de desenvolvimento de código nestes aspectos.

 

 

Conclusão

 

Tentamos abordar aqui situações simples, mas que exemplificam a realidade e os cuidados necessários quando estamos desenvolvendo código. É uma boa iniciativa pensarmos de maneira não tão pragmática, mas um pouco filosófica em se desenvolver sistemas embarcados. O estilo de código não está relacionado a apenas a forma com que você escreve o código, mas também a forma em que ele é desenvolvido e as precauções utilizadas para torná-lo mais seguro. Portanto, o desenvolvimento de código também envolve questões filosóficas que determinam a maneira em que adotamos para desenvolver código e, consequentemente isso atinge nossa produtividade de maneira positiva ou negativa dependendo do que utilizamos como desenvolvimento. Se basear por estilos utilizados pela comunidade é um bom exercício, inclusive para quem está iniciando no desenvolvimento de software e gostaria de estar mais familiarizado com o “mundo real” de desenvolvimento.

NEWSLETTER

Receba os melhores conteúdos sobre sistemas eletrônicos embarcados, dicas, tutoriais e promoções.

Obrigado! Sua inscrição foi um sucesso.

Ops, algo deu errado. Por favor tente novamente.

Licença Creative Commons Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.

recentes antigos mais votados
Notificar
Humberto Kramm
Visitante
Humberto Kramm

cai no erro de não comentar um case que deveria passar dentro de outro. Quando fui revisar o código um tempo depois fiquei apavorado: "mas como que eu esqueci de colocar o break??". Após fazer a "correção" e ver que nada funcionava me lembrei que seria interessante que aquele case entrasse dentro do outro para corrigir o problema. heheh

Cassiano Campes
Visitante
Cassiano Campes

Olá Humberto!

Obrigado pela sua participação. Pois é, documentar o código nos poupa algumas dores de cabeça, como essa que tu descreveu. Por mais bobo que possa parecer, este hábito de documentar nos salva de muitos problemas, e até mesmo tempo de depuração e desenvolvimento.

Marcelo Jo
Visitante
Marcelo Jo

O desafio mesmo é manter os comentários atualizados! =D Comentários desatualizados são tão ruins qto código não comentado!