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.