👨‍💻 C Avançado - Segredos que a Faculdade Não Revela

out. 14, 2024·
OLIVEIRA
OLIVEIRA
· 9 minutos de leitura

Introdução

Olá, seja bem-vindo a este “mini-curso” onde eu vou explicar conceitos intermediários e avançados na linguagem de programação C que eu aprendi codando muito nela. Espero que gostem dos conceitos que irei trazer, e qualquer dúvida, faça um comentário ou entre em contato!


Variáveis com tamanhos variados

Ao desenvolver programas em C, especialmente para sistemas embarcados ou aplicações de baixo nível, é fundamental ter controle preciso sobre o tamanho das variáveis. Isso é particularmente útil em cenários onde o uso eficiente da memória e a compatibilidade entre diferentes plataformas são cruciais. Para esse fim, o padrão C99 introduziu tipos de dados com tamanhos fixos e sinalização explícita.

Vamos explorar esses tipos de dados e entender suas particularidades.

O Problema dos Tipos de Dados Comuns

Tipos básicos como int, char, e long são amplamente usados, mas têm uma característica problemática: o tamanho desses tipos varia dependendo da plataforma. Por exemplo, um int pode ter 16 bits em um microcontrolador de 16 bits ou 32 bits em um processador de 64 bits. Isso pode causar problemas de portabilidade e inconsistência em diferentes arquiteturas.

Tipos de Dados com Tamanhos Fixos

No cabeçalho <stdint.h>, temos uma série de tipos de dados definidos com tamanho e sinalização explícitos. A seguir, veremos uma tabela que resume os principais tipos de dados com tamanhos fixos em C.

TipoTamanho (bits)Faixa de Valores
int8_t8-128 a 127
uint8_t80 a 255
int16_t16-32.768 a 32.767
uint16_t160 a 65.535
int32_t32-2.147.483.648 a 2.147.483.647
uint32_t320 a 4.294.967.295
int64_t64-9.223.372.036.854.775.808 a 9.223.372.036.854.775.807
uint64_t640 a 18.446.744.073.709.551.615
intptr_tDepende da arquiteturaPode armazenar um ponteiro
uintptr_tDepende da arquiteturaPode armazenar um ponteiro

Tipos Opcionais para Otimização

TipoTamanho (mínimo)Descrição
int_fast8_t≥ 8Inteiro mais rápido com pelo menos 8 bits
int_least8_t≥ 8Inteiro com pelo menos 8 bits
int_fast16_t≥ 16Inteiro mais rápido com pelo menos 16 bits
int_least16_t≥ 16Inteiro com pelo menos 16 bits
int_fast32_t≥ 32Inteiro mais rápido com pelo menos 32 bits
int_least32_t≥ 32Inteiro com pelo menos 32 bits

Essas tabelas servem como uma referência rápida para os tipos com tamanhos fixos, garantindo que você possa escolher o tipo adequado para cada situação, considerando o tamanho de bits necessário e a sinalização.

Esses tipos garantem que, independentemente da plataforma, você estará lidando exatamente com o número de bits esperado, o que melhora a portabilidade e a segurança do código.

Além dos tipos principais, o <stdint.h> também define alguns tipos úteis que ajudam a garantir portabilidade e compatibilidade entre plataformas:

  • intptr_t e uintptr_t: Tipos de inteiros que podem armazenar um ponteiro. O tamanho desses tipos é ajustado para garantir que eles possam armazenar qualquer ponteiro, seja em um sistema de 32 ou 64 bits.
  • int_fast8_t e int_least8_t: Esses tipos são usados para otimizar o uso de inteiros de tamanho fixo. int_fast8_t, por exemplo, é o tipo inteiro mais rápido com pelo menos 8 bits, enquanto int_least8_t é o tipo inteiro com o menor tamanho possível, mas com pelo menos 8 bits.

Exercícios

Aqui estão três perguntas sobre o tema de variáveis com tamanhos fixos em C:

  1. Por que o uso de tipos de dados como uint8_t e int16_t é mais vantajoso em sistemas embarcados do que os tipos de dados padrão como int ou char?
👉 Clique para ver a solução
Os tipos como uint8_t e int16_t garantem o tamanho exato da variável, economizando memória e assegurando portabilidade entre plataformas com diferentes arquiteturas.
  1. Qual é a diferença entre os tipos int_fast16_t e int_least16_t? Em quais cenários cada um deles seria mais apropriado?
👉 Clique para ver a solução
int_fast16_t E o inteiro mais rápido com ao menos 16 bits, enquanto int_least16_t é o menor tipo possível com pelo menos 16 bits. Use int_fast16_t quando o desempenho for prioritário e int_least16_t para economizar memória.
  1. O que acontece se tentarmos armazenar um valor fora da faixa permitida para um tipo de dado como uint8_t? Como o comportamento é definido na especificação do C?
👉 Clique para ver a solução
Armazenar um valor fora da faixa de uint8_t (0 a 255) causa overflow, resultando em comportamento indefinido para tipos com sinal ou em wraparound (ciclo de volta) para tipos sem sinal.

Ponteiros de Funções

Nesta seção, exploraremos um conceito avançado, mas extremamente útil em C: os ponteiros de funções. Se você já está familiarizado com funções, isso será um passo adiante que pode simplificar e flexibilizar seu código.

Estruturas de Exemplo

Primeiro, vamos considerar as seguintes estruturas que representam um grafo:

// Nó do Grafo
typedef struct Node {
    int32_t vertex;
    struct Node* next;
} Node;

// Representação do Grafo
typedef struct Graph {
    int32_t numVertices;          // Número de vértices
    Node** adjLists;              // Lista de adjacência
} Graph;

Temos a seguinte função que imprime o vértice de um nó:

void printNode(Node* node) {
    printf("%i", node->vertex);
}

Para imprimir a lista de adjacência de cada vértice no grafo, podemos usar a seguinte função:

void printGraph(Graph* graph) {
    for (int v = 0; v < graph->numVertices; v++) {
        Node* temp = graph->adjLists[v];
        printf("\nLista de adjacência do vértice %d: ", v);
        while (temp) {
            printNode(temp->vertex);
            temp = temp->next;
        }
        printf("\n");
    }
}
Essa abordagem é funcional, mas e se quisermos imprimir outras características dos nós ou reduzir a repetição de código? É aqui que os ponteiros de funções entram em cena.

Integrando Ponteiros de Funções

Podemos modificar a estrutura do nó para incluir um ponteiro para uma função de impressão personalizada:

typedef struct Node {
    int32_t vertex;
    struct Node* next;
    void (*printNode)(Node*);
} Node;

Aqui, void (printNode)(Node) declara um ponteiro para uma função que aceita um parâmetro do tipo Node* e não retorna nada. Para definir a função que será apontada, podemos criar uma nova função de impressão:

void printVertexA(Node* node) {
    printf("%i", node->vertex);
}

void printVertexB(Node* node) {
    printf("%i", node->vertex);
    printf(" Este é um vértice B!\n");
}

Agora, vamos criar uma função para inicializar um nó, associando a função de impressão desejada:

Node* createNode(int32_t vertex, void (*func)(Node*)) {
    Node* node = (Node*)malloc(sizeof(Node));
    node->vertex = vertex;
    node->next = NULL;
    node->printNode = func;
    return node;
}

Atualizando a Função de Impressão com Ponteiro de função

Por fim, ajustamos a função printGraph para utilizar o ponteiro de função de impressão:

void printGraph(Graph* graph) {
    for (int v = 0; v < graph->numVertices; v++) {
        Node* temp = graph->adjLists[v];
        printf("\nLista de adjacência do vértice %d: ", v);
        while (temp) {
            temp->printNode(temp); // Chama a função de impressão associada
            temp = temp->next;
        }
        printf("\n");
    }
}

Exercícios

Explique como declarar um ponteiro para uma função que recebe dois inteiros e retorna um inteiro. Em seguida, crie uma função que some dois números e utilize o ponteiro para chamar essa função e imprimir o resultado.

👉 Clique para ver a solução

Solução:

  1. Declaração do ponteiro de função:

    int (*funcPtr)(int, int);
    
  2. Definição da função que soma dois números:

    int soma(int a, int b) {
        return a + b;
    }
    
  3. Uso do ponteiro para chamar a função:

    #include <stdio.h>
    
    int soma(int a, int b) {
        return a + b;
    }
    
    int main() {
        int (*funcPtr)(int, int) = soma;
        int resultado = funcPtr(5, 3);
        printf("Resultado: %d\n", resultado);
        return 0;
    }
    

Conclusão

Com os ponteiros de funções, podemos facilmente personalizar o comportamento de impressão dos nós, promovendo um código mais modular e reduzindo a duplicação. Essa técnica é especialmente poderosa em estruturas de dados como grafos, onde diferentes tipos de nós podem exigir diferentes métodos de impressão ou manipulação.

Aproveite essa funcionalidade poderosa e explore como os ponteiros de funções podem tornar seu código C ainda mais eficiente e flexível!


Bit Flags

Bit flags permitem representar múltiplos valores binários (true, false) em uma única variável. Isso é útil para economizar memória e facilita operações de habilitar, desabilitar e verificar estados específicos usando operações bitwise.

Imagine que estamos criando um jogo e queremos definir os poderes de um personagem: voar, invisibilidade, super força, etc. Podemos usar int com bit flags para representá-los.

Exemplo em do uso de bit flags:

#include <stdio.h>

// Definindo as flags como valores binários
#define POWER_FLY       (1 << 0) // 0001
#define POWER_INVISIBLE (1 << 1) // 0010
#define POWER_STRENGTH  (1 << 2) // 0100
#define POWER_SPEED     (1 << 3) // 1000

int main() {
    int playerPowers = 0;

    // Ativar voar e invisibilidade usando o operador "or"
    playerPowers |= POWER_FLY | POWER_INVISIBLE;

    // Verificar se o jogador pode voar usando o operador "and"
    if (playerPowers & POWER_FLY) {
        printf("Player can fly!\n");
    }

    // Desativar invisibilidade
    playerPowers &= ~POWER_INVISIBLE;

    return 0;
}

Neste exemplo:

  • |= ativa uma habilidade.
  • & com o valor da flag verifica se a habilidade está ativa.
  • &= ~ desativa a habilidade.

Void Pointers

Um ponteiro void é um tipo de ponteiro “genérico” que pode apontar para qualquer tipo de dado. Ele é útil quando não sabemos o tipo exato dos dados que serão manipulados. No entanto, para acessar o dado, precisamos fazer um cast para o tipo correto.

Curiosidade!

O tão usado e odiado malloc utiliza o tipo de dado void * em sua definicão, pois não sabe o tamanho que precisa alocar!:

void *malloc(size_t size);

No caso de malloc, você pode alocar memória para qualquer tipo de dado e, em seguida, converter o ponteiro void * para o tipo desejado.

Exemplo em C:

#include <stdio.h>

void printValue(void *ptr, char type) {
    if (type == 'i') {
        printf("Integer: %d\n", *(int*)ptr);  // Cast para int
    } else if (type == 'f') {
        printf("Float: %.2f\n", *(float*)ptr); // Cast para float
    }
}

int main() {
    int i = 42;
    float f = 3.14;

    printValue(&i, 'i');  // Passa int
    printValue(&f, 'f');  // Passa float

    return 0;
}

Aqui, void *ptr permite que printValue receba qualquer tipo de dado, mas fazemos o cast para o tipo correto antes de acessar o valor.


Macro Functions

Macros são trechos de código substituídos diretamente no pré-processamento. As macro funções são definidas com #define e podem funcionar como “funções em linha” para simplificar o código e melhorar a performance. Contudo, macros não têm verificação de tipo, então devem ser usadas com cuidado.

Exemplo de função de macro:

#include <stdio.h>

/* retorna o maior valor /*
#define MAX(a, b) ((a) > (b) ? (a) : (b)) 

int main() {
    int x = 10, y = 20;

    printf("Maior valor: %d\n", MAX(x, y));

    return 0;
}

Aqui, MAX é uma macro que retorna o maior entre a e b. A expressão:

(a) > (b) ? (a) : (b)

é substituída diretamente no código onde MAX é usado.