A imutabilidade é um princípio fundamental da programação funcional e orientada a objetos que garante que, uma vez criado, um objeto não possa ter seu estado alterado. No PHP, a partir da versão 8.1, temos a palavra-chave readonly para propriedades de classes, que facilita a criação de objetos imutáveis de forma concisa e segura. Nesta aula, vamos explorar como usar readonly, o que são objetos imutáveis e value objects, e como essas técnicas podem melhorar a qualidade do seu código.

Entender imutabilidade é essencial para escrever código mais previsível, livre de efeitos colaterais e mais fácil de testar. Veremos na prática como aplicar esses conceitos em PHP moderno.

readonly (PHP 8.1)

A partir do PHP 8.1, você pode declarar propriedades de uma classe como readonly. Isso significa que a propriedade só pode ser atribuída uma vez, geralmente no construtor, e não pode ser modificada depois. Essa característica é especialmente útil para criar objetos imutáveis de forma simples, sem a necessidade de escrever getters manualmente ou métodos de modificação proibidos.

Uma propriedade readonly pode ser declarada com ou sem tipo, e pode ser inicializada diretamente na declaração ou no construtor. Após a inicialização, qualquer tentativa de modificar a propriedade resulta em um erro fatal. É importante notar que readonly só pode ser aplicada a propriedades tipadas (com tipo declarado) ou com valor padrão. Além disso, a propriedade não pode ser static.

class Pessoa {
    public readonly string $nome;
    public readonly int $idade;

    public function __construct(string $nome, int $idade) {
        $this->nome = $nome;
        $this->idade = $idade;
    }
}

$pessoa = new Pessoa('João', 30);
echo $pessoa->nome; // João
// $pessoa->nome = 'Maria'; // Erro fatal: Cannot modify readonly property

No exemplo acima, as propriedades $nome e $idade são readonly, definidas no construtor. Qualquer tentativa de alterá-las após a construção resulta em erro. Isso garante que o objeto seja imutável após a criação.

Objetos imutáveis

Um objeto imutável é aquele cujo estado não pode ser alterado após sua criação. Em vez de modificar o objeto, você cria um novo objeto com as alterações desejadas. Isso traz benefícios como segurança em ambientes concorrentes, previsibilidade e facilidade de teste. No PHP, a imutabilidade pode ser alcançada combinando propriedades readonly com a ausência de métodos que modifiquem o estado interno.

Objetos imutáveis são comuns em value objects e em programação funcional. Eles garantem que um objeto, uma vez criado, sempre represente o mesmo valor. Isso evita bugs causados por alterações inesperadas em objetos compartilhados.

class Endereco {
    public readonly string $rua;
    public readonly string $cidade;
    public readonly string $cep;

    public function __construct(string $rua, string $cidade, string $cep) {
        $this->rua = $rua;
        $this->cidade = $cidade;
        $this->cep = $cep;
    }

    // Método que retorna um novo objeto com a rua alterada
    public function withRua(string $novaRua): self {
        return new self($novaRua, $this->cidade, $this->cep);
    }
}

$enderecoOriginal = new Endereco('Rua A', 'São Paulo', '01001-000');
$enderecoModificado = $enderecoOriginal->withRua('Rua B');
echo $enderecoOriginal->rua; // Rua A (imutável)
echo $enderecoModificado->rua; // Rua B (novo objeto)

No exemplo, o método withRua não modifica o objeto original, mas cria um novo. Isso é uma prática comum em objetos imutáveis.

Value objects

Value objects são objetos que representam um valor conceitual, como dinheiro, cor, coordenadas, etc. Eles são imutáveis e se comparam pelo seu conteúdo, não por identidade. Dois value objects com os mesmos valores são considerados iguais. Em PHP, value objects são frequentemente implementados com propriedades readonly e métodos de comparação.

Value objects ajudam a evitar primitives obsession (obsessão por tipos primitivos) e tornam o código mais expressivo. Por exemplo, em vez de passar um array ou string para representar um endereço, você cria uma classe Endereco com validação e comportamento encapsulados.

class Dinheiro {
    public readonly float $valor;
    public readonly string $moeda;

    public function __construct(float $valor, string $moeda) {
        if ($valor < 0) {
            throw new \InvalidArgumentException('Valor não pode ser negativo');
        }
        $this->valor = $valor;
        $this->moeda = $moeda;
    }

    public function somar(Dinheiro $outro): self {
        if ($this->moeda !== $outro->moeda) {
            throw new \InvalidArgumentException('Moedas diferentes');
        }
        return new self($this->valor + $outro->valor, $this->moeda);
    }

    public function equals(Dinheiro $outro): bool {
        return $this->valor === $outro->valor && $this->moeda === $outro->moeda;
    }
}

$dezReais = new Dinheiro(10.0, 'BRL');
$vinteReais = $dezReais->somar(new Dinheiro(10.0, 'BRL'));
echo $vinteReais->valor; // 20.0

Value objects como Dinheiro encapsulam regras de negócio (validação, operações) e garantem que valores inconsistentes não sejam criados.

Exemplos

Vamos ver um exemplo completo que combina todos os conceitos: uma classe Usuario imutável com propriedades readonly e um value object Email para representar o email do usuário.

readonly class Email {
    public function __construct(
        public string $endereco
    ) {
        if (!filter_var($endereco, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Email inválido');
        }
    }

    public function equals(Email $outro): bool {
        return $this->endereco === $outro->endereco;
    }
}

readonly class Usuario {
    public function __construct(
        public string $nome,
        public Email $email,
        public int $idade
    ) {}

    public function withNome(string $novoNome): self {
        return new self($novoNome, $this->email, $this->idade);
    }
}

$email = new Email('joao@example.com');
$usuario = new Usuario('João', $email, 30);
$usuarioNovo = $usuario->withNome('João Silva');
echo $usuario->nome; // João
echo $usuarioNovo->nome; // João Silva

Neste exemplo, a classe Email é um value object que valida o email na construção. A classe Usuario é imutável, com todas as propriedades readonly. O método withNome retorna um novo usuário com o nome alterado. Isso garante que o objeto original permaneça inalterado.

Boas práticas

Ao trabalhar com imutabilidade em PHP, considere: sempre que possível, declare propriedades como readonly para evitar modificações acidentais; use value objects para representar conceitos do domínio; evite métodos que modifiquem o estado interno; e, se precisar de alterações, crie novos objetos. Além disso, lembre-se de que a imutabilidade pode ter custo de performance devido à criação de novos objetos, mas em muitos casos os benefícios superam esse custo.

Referências

Exercícios

  1. Crie uma classe Ponto que represente um ponto no plano cartesiano (coordenadas x e y). A classe deve ser imutável, com propriedades readonly. Implemente um método mover que retorne um novo ponto com coordenadas alteradas.

    ✓ Resposta:
    readonly class Ponto {
        public function __construct(
            public float $x,
            public float $y
        ) {}
    
        public function mover(float $dx, float $dy): self {
            return new self($this->x + $dx, $this->y + $dy);
        }
    }
    
  2. Implemente um value object CPF que valide o formato (apenas números, 11 dígitos) e seja imutável. Inclua um método formatado que retorne o CPF no formato XXX.XXX.XXX-XX.

    ✓ Resposta:
    readonly class CPF {
        public function __construct(
            public string $numero
        ) {
            if (!preg_match('/^\d{11}$/', $numero)) {
                throw new \InvalidArgumentException('CPF deve ter 11 dígitos');
            }
        }
    
        public function formatado(): string {
            return substr($this->numero, 0, 3) . '.' .
                   substr($this->numero, 3, 3) . '.' .
                   substr($this->numero, 6, 3) . '-' .
                   substr($this->numero, 9, 2);
        }
    }
    
  3. Escreva uma classe ContaBancaria imutável com propriedades titular (string), saldo (float) e moeda (string). Crie um método depositar que retorne uma nova conta com saldo aumentado.

    ✓ Resposta:
    readonly class ContaBancaria {
        public function __construct(
            public string $titular,
            public float $saldo,
            public string $moeda
        ) {}
    
        public function depositar(float $valor): self {
            return new self($this->titular, $this->saldo + $valor, $this->moeda);
        }
    }
    
  4. Crie uma classe Cor (value object) que represente uma cor RGB (vermelho, verde, azul). As propriedades devem ser inteiros entre 0 e 255. Implemente um método escurecer que retorne uma nova cor com cada componente reduzido em 10 (sem ultrapassar 0).

    ✓ Resposta:
    readonly class Cor {
        public function __construct(
            public int $r,
            public int $g,
            public int $b
        ) {
            if ($r < 0 || $r > 255 || $g < 0 || $g > 255 || $b < 0 || $b > 255) {
                throw new \InvalidArgumentException('Componentes devem estar entre 0 e 255');
            }
        }
    
        public function escurecer(): self {
            return new self(
                max(0, $this->r - 10),
                max(0, $this->g - 10),
                max(0, $this->b - 10)
            );
        }
    }
    
  5. Implemente um value object Data que armazene dia, mês e ano (inteiros). O objeto deve ser imutável e validar se a data é válida (ex: 29/02 em ano bissexto). Inclua um método proximoDia que retorne a data do dia seguinte.

    ✓ Resposta:
    readonly class Data {
        public function __construct(
            public int $dia,
            public int $mes,
            public int $ano
        ) {
            if (!checkdate($mes, $dia, $ano)) {
                throw new \InvalidArgumentException('Data inválida');
            }
        }
    
        public function proximoDia(): self {
            $timestamp = mktime(0, 0, 0, $this->mes, $this->dia, $this->ano);
            $timestamp = strtotime('+1 day', $timestamp);
            return new self((int)date('j', $timestamp), (int)date('n', $timestamp), (int)date('Y', $timestamp));
        }
    }