Módulo de Álgebra Linear
Módulo de algebra linear da biblioteca Uraipuru.
Quando comecei a escrever minha biblioteca de Machine Learning em C++, a primeira pergunta foi: como vou representar matrizes?
Usar std::vector<std::vector<double>> seria o caminho mais fácil, mas queria entender de verdade o que acontece por baixo dos panos — e ter controle total sobre performance e interface. Então decidi escrever meu próprio módulo de matrizes. Neste post, vou mostrar como ele funciona, as decisões de design que tomei, e por que cada uma delas faz sentido no contexto de ML.
Por que gerenciar memória manualmente?
A primeira decisão foi usar um ponteiro cru (double*) para armazenar os dados, em vez de std::vector.
1
2
3
4
5
6
7
class matriz {
private:
int linhas;
int colunas;
double* dados; // ponteiro para o array flat
...
};
A ideia central aqui é guardar todos os elementos em um array unidimensional contíguo na memória, mesmo que a matriz seja bidimensional. Para acessar o elemento da linha i e coluna j, uso a fórmula:
1
dados[i * colunas + j]
Isso se chama row-major order (ordem por linha), e é exatamente como C e C++ organizam arrays multidimensionais nativamente.
Qual é o ganho real disso?
Em ML, você vai multiplicar matrizes milhares de vezes — em cada passo de treinamento de uma rede neural, por exemplo. Com memória contígua, a CPU consegue pré-carregar os dados no cache de forma eficiente, reduzindo drasticamente o número de cache misses. Com vector<vector<double>>, cada linha seria um bloco separado de memória, espalhado pelo heap — muito menos eficiente.
Os construtores: flexibilidade desde o início
O módulo tem três formas de criar uma matriz:
1. Construtor padrão — cria uma matriz vazia:
1
matriz m; // linhas=0, colunas=0, dados=nullptr
2. Construtor por dimensões — aloca e inicializa tudo com zero:
1
matriz m(3, 4); // matriz 3x4 de zeros
3. Construtor com initializer list — ideal para testes e matrizes pequenas:
1
2
3
4
matriz m = {
{1.0, 2.0, 3.0},
{4.0, 5.0, 6.0}
};
Esse último usa std::initializer_list<std::initializer_list<double>>, o que permite aquela sintaxe de chaves aninhadas bem legível. Internamente, ele percorre as listas e preenche o array:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
matriz::matriz(std::initializer_list<std::initializer_list<double>> m) {
this->linhas = m.size();
this->colunas = m.begin()->size();
this->dados = new double[linhas * colunas];
int i = 0;
for (auto linha : m) {
int j = 0;
for (double valor : linha) {
this->dados[i * this->colunas + j] = valor;
j++;
}
i++;
}
}
A regra dos três: destrutor, cópia e atribuição
Sempre que você gerencia memória manualmente em C++, precisa implementar a Regra dos Três: destrutor, construtor de cópia e operador de atribuição. Se negligenciar qualquer um deles, você vai ter double free ou memory leak na certa.
Destrutor — libera a memória alocada:
1
2
3
matriz::~matriz() {
delete[] dados;
}
Construtor de cópia — faz uma cópia profunda (deep copy):
1
2
3
4
5
matriz::matriz(const matriz& outra) : linhas(outra.linhas), colunas(outra.colunas) {
dados = new double[linhas * colunas];
for (int i = 0; i < linhas * colunas; i++)
dados[i] = outra.dados[i];
}
Operador de atribuição — cuida do caso especial a = a e libera a memória antiga antes de copiar:
1
2
3
4
5
6
7
8
9
10
11
12
matriz& matriz::operator=(const matriz& outra) {
if (this == &outra) return *this; // autoatribuição!
delete[] dados;
linhas = outra.linhas;
colunas = outra.colunas;
dados = new double[linhas * colunas];
for (int i = 0; i < linhas * colunas; i++)
dados[i] = outra.dados[i];
return *this;
}
Operadores sobrecarregados: escrevendo matemática de verdade
Essa é a parte mais legal do módulo. Com sobrecarga de operadores, o código de ML fica muito mais próximo da notação matemática real.
Soma e subtração
Ambas verificam se as dimensões são compatíveis e operam elemento a elemento:
1
2
matriz c = a + b; // lança std::invalid_argument se dimensões diferirem
matriz d = a - b;
Internamente, é um loop simples sobre o array flat:
1
2
for (int i = 0; i < linhasA * colunasA; i++)
c.dados[i] = this->dados[i] + b.dados[i];
Multiplicação por escalar
Suporta tanto matriz * 2.0 quanto 2.0 * matriz, graças a uma função friend:
1
2
3
4
5
6
7
// Dentro da classe
matriz operator*(double num) const;
// Fora da classe (friend)
matriz operator*(double num, const matriz& a) {
return a * num; // delega para o método da classe
}
Uso:
1
2
matriz b = 3.0 * a; // funciona!
matriz c = a * 3.0; // também funciona!
Multiplicação de matrizes
Essa é a operação mais importante para ML — é o coração de qualquer camada de uma rede neural. A implementação segue o algoritmo clássico O(n³):
1
2
3
4
5
6
7
8
9
10
11
12
matriz matriz::operator*(const matriz& b) const {
if (colunas != b.linhas)
throw std::invalid_argument("Dimensões incompatíveis para multiplicação");
matriz c(linhas, b.colunas);
for (int i = 0; i < linhas; i++)
for (int j = 0; j < b.colunas; j++)
for (int k = 0; k < colunas; k++)
c.dados[i * b.colunas + j] += this->dados[i * colunas + k] * b.dados[k * b.colunas + j];
return c;
}
Para entender por que colunas == b.linhas é obrigatório: se \(A é m \times n\) e $B$ é $n \times p$, o produto $AB$ é $m \times p$. Se essa condição não for satisfeita, a operação simplesmente não faz sentido matematicamente.
Transposta
Útil em backpropagation e em várias outras operações de ML. A transposta de uma matriz $m \times n$ é uma $n \times m$ onde $A^T[j][i] = A[i][j]$:
1
matriz aT = a.transposta();
Acesso por índice com operator()
Para ler e escrever elementos de forma natural:
1
double& operator()(int i, int j);
Uso:
1
2
m(0, 1) = 42.0;
double x = m(2, 3);
Exemplo de uso completo
Veja como o módulo fica na prática:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "matriz.h"
int main() {
// Criar matrizes com initializer list
matriz pesos = {
{0.1, 0.2, 0.3},
{0.4, 0.5, 0.6}
};
matriz entrada = {
{1.0},
{2.0},
{3.0}
};
// Multiplicação de matrizes (camada linear de uma rede neural)
matriz saida = pesos * entrada;
// Escalar o resultado
matriz saida_escalada = 0.5 * saida;
// Transposta (útil no backprop)
matriz pesosT = pesos.transposta();
// Imprimir
std::cout << saida << std::endl;
return 0;
}
