Poucas linguagens permitem ao programador expressar suas intenções, manipulando dados em memória, com tanta riqueza quanto C++. Exemplo disso, é uma funcionalidade da linguagem, extremamente poderosa, ausente na maioria das linguagens mainstream, conhecida como const correctness.
Mais do mesmo…
Para entender o poder de const correctness, vamos, antes, revisitar duas features mais simples, comuns a quase todas as linguagens: passagem de parâmetros “por valor” e “por referência”
No exemplo que segue, uma string é passada “por valor”. Ou seja, um “objeto-cópia” é criado quando a função é acionada e destruído no momento em que ela retorna.
#include <iostream> #include <string> // s is passed by value void f(std::string s) { s.clear(); std::cout << "F: S is empty: " << s.empty() << std::endl; } int main() { std::string s("This is my string"); f(s); std::cout << "main: S is empty: " << s.empty() << std::endl; std::cout << "main: S value : " << s << std::endl; return 0; } // OUTPUT // F: S is empty : 1 // main: S is empty: 0 // main: S value : This is my string
O resultado desse código é que a string original, criada na função main
, permanece seguramente inalterada, enquanto uma nova string é manipulada na função f
.
Já no exemplo que segue, é indicado que o valor recebido como parâmetro é passado “por referência”.
#include <iostream> #include <string> // s is a reference void f(std::string& s) { s.clear(); std::cout << "F: S is empty: " << s.empty() << std::endl; } int main() { std::string s("This is my string"); f(s); std::cout << "main: S is empty: " << s.empty() << std::endl; std::cout << "main: S value : " << s << std::endl; return 0; } // OUTPUT // F: S is empty : 1 // main: S is empty: 1 // main: S value :
O resultado do código acima é que a string original, criada na função main
, é a mesma recebida e manipulada na função f
.
Um problema quase sem solução, fora do C++
Como previnir que um programador realize alterações, inadvertidamente, no estado de um objeto passado como parâmetro “por referência”? Em C++, basta marcar tal parâmetro como const
.
#include <iostream> #include <string> // s is a reference void f(const std::string& s) { s.clear(); std::cout << "F: S is empty: " << s.empty() << std::endl; } int main() { std::string s("This is my string"); f(s); std::cout << "main: S is empty: " << s.empty() << std::endl; std::cout << "main: S value : " << s << std::endl; return 0; }
O que acontece aqui é uma espécie de “casting” do tipo do objeto passado como parâmetro, onde todos os membros não marcados como const
tornam-se inacessíveis. Qualquer violação é detectada em tempo de compilação, sem prejuízos de performance em tempo de execução.
Como o programador expressa “membros const
” em seus tipos
Cabe ao programador, nas suas implementações de tipos, como a indicada no exemplo que segue, apontar, então, os membros que não realizam modificações de estado (seguras para serem chamadas com const).
class Person { int _age {}; public: auto age() const { return _age; } // this method is not allowed to mutate the object. auto set_age(int age) { _age = age; } };
No exemplo acima, a implementação do método age
, marcado com o modificador const
, cumpre o contrato de não gerar modificações de estado, afinal, apenas retorna um valor.
class Person { int _age {}; int _readcount{}; public: auto age() const { _readcount++; return _age; } auto set_age(int age) { _age = age; } };
Já o código acima não irá compilar porque o compilador consegue identificar que o “compromisso” assumido no contrato (interface pública da classe/struct) não foi respeitado.
Como o programador expressa “retornos const
“
Caso o valor retornado seja uma referência, também é possível determinar se ela poderá, ou não, ser modificada.
class Point2 { int _x; int _y; public: Point2(int x, int y) : _x(x), _y(y) {} auto x() const { return _x; } auto set_x(int x) { _x = x; } auto y() const { return _y; } auto set_y(int y) { _y = y; } }; class Circle { Point2 _center; double _radius; public: Circle(int centerX, int centerY, double radius) : _center(Point2(centerX, centerY)), _radius(radius) {} auto& center() const { return _center; }; auto radius() const { return _radius; }; };
No código acima, por exemplo, o getter center
retorna uma referência “constante” para um objeto do tipo Point2
, evitando, com toda a segurança, a criação de uma “cópia” na memória.
int main() { Circle c(11, 0, 10.0); c.center().set_y(10); // will not compile, set_y requires a mutable object. return 0; }
O exemplo acima, não irá compilar porque há uma tentativa de acessar um membro “não-const” em um tipo indicado como “const”.
Eventualmente, o programador desejará que o retorto de um membro de tipo seja “imutável”, porém, sem impor restrições de que a execução do método em si modifique o estado do objeto. Nesses casos, basta mover a palavra-chave const
para antes da declaração do tipo de retorno.
const auto& center() { return _center; };
O compilador do C++ não ajuda?
Para cumprir seu compromisso de conceder ao programador o máximo de acesso a recursos, para obtenção de performance ótima, em um tempo em que compiladores dispunham de poucos KB para processar bases gigantescas de código, C++ já permitiu (e por compatibilidade retroativa, ainda permite) a escrita de código “perigoso”. Entretanto, a linguagem amadureceu e hoje oferece artifícios simples para impedir grandes enganos. Um desses artifícios é a ideia de “const correctness“.
Trata-se de uma solução genialmente simples para impedir enganos perigosos, simplesmente permitindo que o programador expresse o que deseja do código de maneira cuidadosa. O compilador do C++ não ajuda “direcionando” o programador para “não enganos”, mas garantindo que suas intenções, expressas no código, sejam concretizadas.