Em seu discurso na CppCon 2018, Herb Sutter apresentou ao público suas realizações em duas direções. Primeiramente, ele controla o tempo de vida das variáveis (Lifetime), que detectará classes inteiras de bugs no estágio de compilação. Em segundo lugar, esta é uma proposta atualizada sobre metaclasses , que permitirá evitar a duplicação de código, descrevendo o comportamento de uma categoria de classe e conectando-o a classes específicas com uma linha.
Prefácio: mais = mais fácil ?!
Ouvem-se acusações de C ++ de que o padrão está crescendo sem sentido e sem piedade. Mas mesmo os conservadores mais fervorosos não argumentam que novas construções como range-for (ciclo de coleta) e auto (pelo menos para iteradores) tornam o código mais simples. Você pode desenvolver critérios aproximados que (pelo menos uma, idealmente todas) novas extensões de idioma devem atender para simplificar o código na prática:
- Reduza, simplifique o código, remova o código duplicado (intervalo para, automático, lambda, Metaclasses)
- Facilite a escrita de códigos seguros, evite erros e casos especiais (indicadores inteligentes, vida útil)
- Substitua completamente os recursos antigos e menos funcionais (typedef → usando)
Herb Sutter identifica "C ++ moderno" - um subconjunto de recursos que atendem aos padrões modernos de codificação (como as Diretrizes Principais do C ++ ) e considera o padrão completo como um "modo de compatibilidade", que nem todos precisam conhecer. Assim, se o "C ++ moderno" não crescer, tudo estará bem.
Verificando a vida útil das variáveis (Lifetime)
O novo Lifetime Verification Group agora está disponível como parte do Core Guidelines Checker para Clang e Visual C ++. O objetivo não é atingir rigor e precisão absolutos, como no Rust, mas executar verificações simples e rápidas nas funções individuais.
Princípios básicos de verificação
Do ponto de vista da análise do tempo de vida, os tipos são divididos em 3 categorias:
- O valor é o que um ponteiro pode apontar.
- Ponteiro - refere-se ao Valor, mas não controla sua vida útil. Pode estar pendurado (ponteiro pendente). Exemplos:
T*
, T&
, iteradores, std::observer_ptr<T>
, std::string_view
, gsl::span<T>
- Proprietário - controla a vida útil do valor. Geralmente pode excluir seu valor antes do previsto. Exemplos:
std::unique_ptr<T>
, std::shared_ptr<T>
, std::vector<T>
, std::string
, gsl::owner<T*>
Um ponteiro pode estar em um dos seguintes estados:
- Aponte para um valor armazenado na pilha
- Aponte para um valor contido "por dentro" por algum proprietário
- Estar vazio (nulo)
- Travar (inválido)
Ponteiros e valores
Para cada ponteiro p é rastreado p s e t ( p ) - o conjunto de valores aos quais isso pode indicar. Ao excluir um Valor, sua ocorrência em todos p s e t substituído por i n v á l i d o . Ao acessar um valor de ponteiro p tal que inválido∈pset(p) emitir um erro.
string_view s;
Usando anotações, você pode configurar quais operações serão consideradas operações de acesso ao Valor. Por padrão: *
, ->
, []
, begin()
, end()
.
Observe que o aviso é emitido apenas no momento do acesso ao índice inválido. Se o Valor for excluído, mas ninguém nunca acessar esse Ponteiro, tudo estará em ordem.
Sinalização e Proprietários
Se ponteiro p indica um valor contido no proprietário o então isso pset(p)=o′ .
Métodos e funções que levam proprietários, são divididos em:
- Operações de acesso ao valor do proprietário. Padrão:
*
, ->
, []
, begin()
, end()
- Acesse operações ao próprio proprietário, ponteiros
v.clear()
, como v.clear()
. Por padrão, essas são todas as outras operações não-const - Acesse operações ao próprio Proprietário, ponteiros não invalidantes, como
v.empty()
. Por padrão, todas essas são operações const.
Anunciado proprietário de conteúdo antigo inválido após a remoção do Proprietário ou mediante a aplicação de operações invalidantes.
Essas regras são suficientes para detectar muitos erros típicos no código C ++:
string_view s;
vector<int> v = get_ints(); int* p = &v[5];
std::string_view s = "foo"s; cout << s[0];
vector<int> v = get_ints(); for (auto i = v.begin(); i != v.end(); ++i) {
std::optional<std::vector<int>> get_data();
Rastreando a vida útil dos parâmetros de função
Quando começamos a lidar com funções em C ++ que retornam ponteiros, podemos apenas adivinhar a relação entre a vida útil dos parâmetros e o valor de retorno. Se uma função aceitar e retornar ponteiros do mesmo tipo, será assumido que a função "obtém" o valor de retorno de um dos parâmetros de entrada:
auto f(int* p, int* q) -> int*;
Funções suspeitas são facilmente detectadas e levam o resultado do nada:
std::reference_wrapper<int> get_data() {
Como é possível passar um valor temporário para os parâmetros const T&
, eles não são levados em consideração, a menos que o resultado não esteja em outro lugar para levar:
template <typename T> const T& min(const T& x, const T& y);
using K = std::string; using V = std::string; const V& find_or_default(const std::map<K, V>& m, const K& key, const V& def);
Também se acredita que, se uma função aceita um ponteiro (em vez de uma referência), ela pode ser nullptr, e esse ponteiro não pode ser usado antes de comparar com nullptr.
Conclusão do controle do tempo de vida
Repito que o Lifetime ainda não é uma proposta para o padrão C ++, mas uma tentativa ousada de implementar verificações vitalícias em C ++, onde, ao contrário do Rust, por exemplo, nunca houve anotações correspondentes. No início, haverá muitos falsos positivos, mas com o tempo, as heurísticas irão melhorar.
Perguntas da platéia
As verificações de grupo ao longo da vida fornecem uma garantia matematicamente precisa da ausência de ponteiros pendentes?
Teoricamente, seria possível (no novo código) pendurar um monte de anotações em classes e funções e, em troca, o compilador daria tais garantias. Mas essas verificações foram desenvolvidas seguindo o princípio 80:20, ou seja, você pode detectar a maioria dos erros usando um pequeno número de regras e aplicando um mínimo de anotações.
A metaclasse de alguma forma complementa o código da classe à qual é aplicada e também serve como o nome de um grupo de classes que satisfazem determinadas condições. Por exemplo, como mostrado abaixo, a metaclasse da interface
tornará todas as funções públicas e puramente virtuais para você.
No ano passado, Herb Sutter fez seu primeiro projeto de metaclasse ( veja aqui ). Desde então, a sintaxe proposta atual mudou.
Para iniciantes, a sintaxe para o uso de metaclasses foi alterada:
Ele se tornou mais longo, mas agora existe uma sintaxe natural para a aplicação de várias metaclasses ao mesmo tempo: class(meta1, meta2)
.
Anteriormente, uma metaclasse era um conjunto de regras para modificar uma classe. Agora uma metaclasse é uma função constexpr que pega uma classe antiga (declarada no código) e cria uma nova.
Ou seja, a função usa um parâmetro - meta-informação sobre a classe antiga (o tipo de parâmetro depende da implementação), cria elementos de classe (fragmentos) e os adiciona ao corpo da nova classe usando a instrução __generate
.
Fragmentos podem ser gerados usando as construções __fragment
, __inject
, idexpr(…)
. O orador preferiu não se concentrar em seu propósito, pois essa parte ainda será alterada antes de ser submetida ao comitê de padronização. Os nomes em si são garantidos para serem alterados, duplo sublinhado foi adicionado especificamente para esclarecer isso. A ênfase no relatório estava em exemplos que vão além.
interface
template <typename T> constexpr void interface(T source) {
Você pode pensar que nas linhas (1) e (2) modificamos a classe original, mas não. Observe que iteramos as funções da classe original com a cópia, modificamos essas funções e as inserimos em uma nova classe.
Aplicação de metaclasse:
class(interface) Shape { int area() const; void scale_by(double factor); };
Depuração Mutex
Suponha que tenhamos dados seguros sem thread protegidos por um mutex. A depuração pode ser facilitada se, em um assembly de depuração, a cada chamada, for verificado se o processo atual bloqueou esse mutex. Para fazer isso, uma classe TestableMutex simples foi escrita:
class TestableMutex { public: void lock() { m.lock(); id = std::this_thread::get_id(); } void unlock() { id = std::thread::id{}; m.unlock(); } bool is_held() { return id == std::this_thread::get_id(); } private: std::mutex m; std::atomic<std::thread::id> id; };
Além disso, em nossa classe MyData, gostaríamos de todos os campos públicos como
vector<int> v;
Substitua por + getter:
private: vector<int> v_; public: vector<int>& v() { assert(m_.is_held()); return v_; }
Para funções, também é possível realizar transformações semelhantes.
Essas tarefas são resolvidas usando macros e geração de código. Herb Sutter declarou guerra às macros: elas são inseguras, ignoram semântica, espaços para nome etc. Como é a solução nas metaclasses:
constexpr void guarded_with_mutex() { __generate __fragment class { TestableMutex m_;
Como usar:
class(guarded) MyData { vector<int> v; Widget* w; }; MyData& x = findData("foo"); xv().clear();
ator
Bem, mesmo se protegemos algum objeto com um mutex, agora tudo é seguro para threads, não há reivindicações de correção. Mas se um objeto puder ser acessado com frequência por muitos threads em paralelo, o mutex ficará sobrecarregado e haverá uma grande sobrecarga para levá-lo.
A solução fundamental para o problema de mutexes com bugs é o conceito de atores, quando um objeto tem uma fila de solicitações, todas as chamadas para o objeto são colocadas em fila e executadas uma após a outra em um encadeamento especial.
Deixe a classe Active conter uma implementação de tudo isso - na verdade, um pool / executor de threads com um único thread. Bem, as metaclasses ajudarão a se livrar do código duplicado e enfileirarão todas as operações:
class(active) ImageFilter { public: ImageFilter(std::function<void(Buffer*)> w) : work(std::move(w)) {} void apply(Buffer* b) { work(b); } private: std::function<void(Buffer*)> work; }
class(active) log { std::fstream f; public: void info(…) { f << …; } };
propriedade
Existem propriedades em quase todas as linguagens de programação modernas, e quem não as implementou com base em C ++: Qt, C ++ / CLI, todos os tipos de macros feias. No entanto, eles nunca serão adicionados ao padrão C ++, pois eles mesmos são considerados recursos muito restritos e sempre houve a esperança de que alguma proposta os implementasse como um caso especial. Bem, eles podem ser implementados em metaclasses!
Você pode definir seu próprio getter e setter:
class Date { public: class(property<int>) MonthClass { int month; auto get() { return month; } void set(int m) { assert(m > 0 && m < 13); month = m; } } month; }; Date date; date.month = 15;
Idealmente, quero escrever property int month { … }
, mas mesmo essa implementação substituirá o zoológico de extensões C ++ que inventam propriedades.
Metaclasses são um grande recurso novo para uma linguagem já complexa. Vale a pena? Aqui estão alguns de seus benefícios:
- Deixe os programadores expressarem suas intenções com mais clareza (eu quero escrever ator)
- Reduza a duplicação de código e simplifique o desenvolvimento e a manutenção de código que segue certos padrões
- Elimine alguns grupos de erros comuns (será suficiente cuidar de todas as sutilezas uma vez)
- Permitir se livrar de macros? (Herb Sutter é muito beligerante)
Perguntas da platéia
Como depurar metaclasses?
Pelo menos para Clang, existe uma função intrínseca que, se chamada, imprimirá o conteúdo real da classe durante a compilação, ou seja, o que acontece após a aplicação de todas as metaclasses.
Dizia-se que era capaz de declarar não membros como swap e hash em metaclasses. Para onde ela foi?
A sintaxe será mais desenvolvida.
Por que precisamos de metaclasses se conceitos já foram adotados para padronização?
Essas são coisas diferentes. Metaclasses são necessárias para definir partes de uma classe, e os conceitos verificam se uma classe corresponde a um determinado padrão usando exemplos de classe. De fato, metaclasses e conceitos funcionam bem juntos. Por exemplo, você pode definir o conceito de um iterador e a metaclasse de um "iterador típico" que define algumas operações redundantes pelo restante.