Isso é algo que sempre me incomodou como um recurso das expressões lambda C ++: o tipo de uma expressão lambda C ++ é único e anônimo, simplesmente não consigo anotá-lo. Mesmo se eu criar dois lambdas que são sintaticamente exatamente iguais, os tipos resultantes são definidos para serem distintos. A conseqüência é que a) lambdas só podem ser passados para funções de template que permitem o tempo de compilação, tipo indizível a ser passado junto com o objeto, eb) que lambdas só são úteis uma vez que seu tipo seja apagado por meio std::function<>
.
Ok, mas é assim que C ++ faz, eu estava pronto para descartá-lo como apenas um recurso enfadonho dessa linguagem. No entanto, acabei de aprender que Rust aparentemente faz o mesmo: cada função Rust ou lambda tem um tipo único e anônimo. E agora estou me perguntando: por quê?
Então, minha pergunta é a seguinte:
Qual é a vantagem, do ponto de vista do designer de linguagem, para introduzir o conceito de um tipo único e anônimo em uma linguagem?
fonte
std::function
. Um lambda que foi passado para uma função de modelo pode ser chamado diretamente sem envolvimentostd::function
. O compilador pode então embutir o lambda na função de modelo, o que aumentará a eficiência do tempo de execução.{ int i = 42; auto foo = [&i](){ return i; }; } { int i = 13; auto foo = [&i](){ return i; }; }
uma vez que a variável a que se refere é diferente, embora textualmente sejam iguais. Se você apenas disser que todos eles são únicos, não precisa se preocupar em tentar descobrir.lambdas_type = decltype( my_lambda);
[](auto) {}
? Deve ter um tipo, para começar?Respostas:
Muitos padrões (especialmente C ++) adotam a abordagem de minimizar o quanto eles exigem dos compiladores. Francamente, eles já exigem o suficiente! Se eles não tiverem que especificar algo para fazer funcionar, eles tendem a deixar a implementação definida.
Se os lambdas não fossem anônimos, teríamos que defini-los. Isso teria a dizer muito sobre como as variáveis são capturadas. Considere o caso de um lambda
[=](){...}
. O tipo teria que especificar quais tipos realmente foram capturados pelo lambda, o que poderia ser não trivial de determinar. Além disso, e se o compilador otimizar com êxito uma variável? Considerar:static const int i = 5; auto f = [i]() { return i; }
Um compilador de otimização poderia reconhecer facilmente que o único valor possível de
i
que poderia ser capturado é 5 e substituí-lo porauto f = []() { return 5; }
. No entanto, se o tipo não for anônimo, isso pode alterar o tipo ou forçar o compilador a otimizar menos, armazenandoi
mesmo que ele realmente não precise disso. Isso é todo um saco de complexidade e nuances que simplesmente não são necessários para o que os lambdas pretendem fazer.E, no caso de você realmente precisar de um tipo não anônimo, você sempre pode construir a classe de encerramento sozinho e trabalhar com um functor em vez de uma função lambda. Assim, eles podem fazer lambdas lidar com o caso de 99%, e deixar você codificar sua própria solução no 1%.
O desduplicador apontou em comentários que eu não trato da exclusividade tanto quanto do anonimato. Estou menos certo dos benefícios da exclusividade, mas é importante notar que o comportamento do seguinte é claro se os tipos forem únicos (a ação será instanciada duas vezes).
int counter() { static int count = 0; return count++; } template <typename FuncT> void action(const FuncT& func) { static int ct = counter(); func(ct); } ... for (int i = 0; i < 5; i++) action([](int j) { std::cout << j << std::endl; }); for (int i = 0; i < 5; i++) action([](int j) { std::cout << j << std::endl; });
Se os tipos não fossem únicos, teríamos que especificar qual comportamento deveria acontecer neste caso. Isso pode ser complicado. Algumas das questões levantadas no tópico do anonimato também levantam sua cabeça feia neste caso para exclusividade.
fonte
Lambdas não são apenas funções, eles são uma função e um estado . Portanto, tanto C ++ quanto Rust os implementam como um objeto com um operador de chamada (
operator()
em C ++, as 3Fn*
características de Rust).Basicamente,
[a] { return a + 1; }
em C ++ desugars para algo comostruct __SomeName { int a; int operator()() { return a + 1; } };
em seguida, usando uma instância de
__SomeName
onde o lambda é usado.Enquanto em Rust,
|| a + 1
em Rust irá desugar para algo como{ struct __SomeName { a: i32, } impl FnOnce<()> for __SomeName { type Output = i32; extern "rust-call" fn call_once(self, args: ()) -> Self::Output { self.a + 1 } } // And FnMut and Fn when necessary __SomeName { a } }
Isso significa que a maioria dos lambdas deve ter tipos diferentes .
Agora, existem algumas maneiras de fazer isso:
Fn*
traços em Rust. Nenhuma das linguagens força você a apagar lambdas para usá-los (std::function
em C ++ ouBox<Fn*>
em Rust).Observe também que ambas as linguagens concordam que lambdas triviais que não capturam contexto podem ser convertidos em ponteiros de função.
Descrever recursos complexos de uma linguagem usando recursos mais simples é bastante comum. Por exemplo, C ++ e Rust têm loops range-for, e ambos os descrevem como sintaxe de açúcar para outros recursos.
C ++ define
for (auto&& [first,second] : mymap) { // use first and second }
como sendo equivalente a
{ init-statement auto && __range = range_expression ; auto __begin = begin_expr ; auto __end = end_expr ; for ( ; __begin != __end; ++__begin) { range_declaration = *__begin; loop_statement } }
e Rust define
for <pat> in <head> { <body> }
como sendo equivalente a
let result = match ::std::iter::IntoIterator::into_iter(<head>) { mut iter => { loop { let <pat> = match ::std::iter::Iterator::next(&mut iter) { ::std::option::Option::Some(val) => val, ::std::option::Option::None => break }; SemiExpr(<body>); } } };
que, embora pareçam mais complicados para um humano, são mais simples para um designer de linguagem ou um compilador.
fonte
std::function
issostd::function
(Adicionando à resposta de Caleth, mas muito longo para caber em um comentário.)
A expressão lambda é apenas um açúcar sintático para uma estrutura anônima (um tipo de Voldemort, porque você não pode dizer seu nome).
Você pode ver a semelhança entre uma estrutura anônima e o anonimato de um lambda neste snippet de código:
#include <iostream> #include <typeinfo> using std::cout; int main() { struct { int x; } foo{5}; struct { int x; } bar{6}; cout << foo.x << " " << bar.x << "\n"; cout << typeid(foo).name() << "\n"; cout << typeid(bar).name() << "\n"; auto baz = [x = 7]() mutable -> int& { return x; }; auto quux = [x = 8]() mutable -> int& { return x; }; cout << baz() << " " << quux() << "\n"; cout << typeid(baz).name() << "\n"; cout << typeid(quux).name() << "\n"; }
Se isso ainda é insatisfatório para um lambda, também deve ser insatisfatório para uma estrutura anônima.
Algumas linguagens permitem um tipo de digitação de pato que é um pouco mais flexível e, embora C ++ tenha modelos que realmente não ajudam a fazer um objeto a partir de um modelo que tem um campo de membro que pode substituir um lambda diretamente em vez de usar um
std::function
embrulho.fonte
int& operator()(){ return x; }
a essas estruturasauto foo(){ struct DarkLord {} tom_riddle; return tom_riddle; }
, porque fora dofoo
nada pode usar o identificadorDarkLord
Porque há casos em que os nomes são irrelevantes e inúteis ou mesmo contraproducentes. Nesse caso, a capacidade de abstrair sua existência é útil porque reduz a poluição de nomes e resolve um dos dois problemas difíceis na ciência da computação (como nomear as coisas). Pelo mesmo motivo, objetos temporários são úteis.
A exclusividade não é uma coisa lambda especial, ou mesmo algo especial para tipos anônimos. Ele também se aplica a tipos nomeados no idioma. Considere o seguinte:
struct A { void operator()(){}; }; struct B { void operator()(){}; }; void foo(A);
Note-se que não posso passar
B
emfoo
, embora as classes são idênticos. Esta mesma propriedade se aplica a tipos não nomeados.Há uma terceira opção para um subconjunto de lambdas: Lambdas que não capturam podem ser convertidos em ponteiros de função.
Observe que, se as limitações de um tipo anônimo forem um problema para um caso de uso, a solução é simples: um tipo nomeado pode ser usado em seu lugar. Lambdas não fazem nada que não possa ser feito com uma classe nomeada.
fonte
A resposta aceita de Cort Ammon é boa, mas acho que há mais um ponto importante a ser feito sobre a implementabilidade.
Suponha que eu tenha duas unidades de tradução diferentes, "one.cpp" e "two.cpp".
// one.cpp struct A { int operator()(int x) const { return x+1; } }; auto b = [](int x) { return x+1; }; using A1 = A; using B1 = decltype(b); extern void foo(A1); extern void foo(B1);
As duas sobrecargas de
foo
usam o mesmo identificador (foo
), mas têm nomes mutilados diferentes. (No Itanium ABI usado em sistemas POSIX-ish, os nomes mutilados são_Z3foo1A
e, neste caso particular_Z3fooN1bMUliE_E
,.)// two.cpp struct A { int operator()(int x) const { return x + 1; } }; auto b = [](int x) { return x + 1; }; using A2 = A; using B2 = decltype(b); void foo(A2) {} void foo(B2) {}
O compilador C ++ deve garantir que o nome mutilado de
void foo(A1)
em "two.cpp" seja o mesmo que o nome mutilado deextern void foo(A2)
em "one.cpp", para que possamos vincular os dois arquivos-objeto. Este é o significado físico de dois tipos sendo "o mesmo tipo": é essencialmente sobre compatibilidade ABI entre arquivos de objeto compilados separadamente.O compilador C ++ não é necessário para garantir que
B1
eB2
são "do mesmo tipo". (Na verdade, é necessário garantir que sejam de tipos diferentes; mas isso não é tão importante agora.)Qual mecanismo físico o compilador usa para garantir que
A1
eA2
sejam "do mesmo tipo"?Ele simplesmente explora typedefs e, em seguida, examina o nome totalmente qualificado do tipo. É um tipo de classe chamado
A
. (Bem,::A
já que está no namespace global.) Portanto, é o mesmo tipo em ambos os casos. Isso é fácil de entender. Mais importante, é fácil de implementar . Para ver se dois tipos de classes são do mesmo tipo, pegue seus nomes e faça astrcmp
. Para transformar um tipo de classe em um nome mutilado de função, você escreve o número de caracteres em seu nome, seguido por esses caracteres.Portanto, os tipos nomeados são fáceis de destruir.
Que mecanismo físico o compilador pode usar para garantir que
B1
eB2
sejam "do mesmo tipo", em um mundo hipotético onde C ++ exigia que fossem do mesmo tipo?Bem, não poderia usar o nome do tipo, porque o tipo não tem um nome.
Talvez pudesse codificar de alguma forma o texto do corpo do lambda. Mas isso seria meio estranho, porque na verdade o
b
em "one.cpp" é sutilmente diferente dob
em "two.cpp": "one.cpp" temx+1
e "two.cpp" temx + 1
. Então, nós teríamos que chegar a uma regra que diz que quer que esta diferença de espaço em branco não importa, ou que ele faz (tornando-os tipos diferentes depois de tudo), ou que talvez sim (talvez a validade do programa é a implementação-definido ou talvez seja "malformado, sem necessidade de diagnóstico"). De qualquer forma,A
A maneira mais fácil de sair da dificuldade é simplesmente dizer que cada expressão lambda produz valores de um tipo único. Então, dois tipos lambda definidos em unidades de tradução diferentes definitivamente não são o mesmo tipo . Em uma única unidade de tradução, podemos "nomear" tipos lambda apenas contando a partir do início do código-fonte:
auto a = [](){}; // a has type $_0 auto b = [](){}; // b has type $_1 auto f(int x) { return [x](int y) { return x+y; }; // f(1) and f(2) both have type $_2 } auto g(float x) { return [x](int y) { return x+y; }; // g(1) and g(2) both have type $_3 }
É claro que esses nomes têm significado apenas nesta unidade de tradução. Este TU
$_0
é sempre um tipo diferente de algum outro TU$_0
, embora este TUstruct A
seja sempre do mesmo tipo que algum outro TUstruct A
.A propósito, observe que nossa idéia de "codificar o texto do lambda" tinha outro problema sutil: lambdas
$_2
e$_3
consistem exatamente no mesmo texto , mas eles claramente não devem ser considerados do mesmo tipo!A propósito, C ++ exige que o compilador saiba como destruir o texto de uma expressão C ++ arbitrária , como em
template<class T> void foo(decltype(T())) {} template void foo<int>(int); // _Z3fooIiEvDTcvT__EE, not _Z3fooIiEvT_
Mas C ++ (ainda) não exige que o compilador saiba como destruir uma instrução C ++ arbitrária .
decltype([](){ ...arbitrary statements... })
ainda está mal formado mesmo em C ++ 20.Observe também que é fácil fornecer um alias local para um tipo sem nome usando
typedef
/using
. Tenho a sensação de que sua pergunta pode ter surgido ao tentar fazer algo que poderia ser resolvido assim.auto f(int x) { return [x](int y) { return x+y; }; } // Give the type an alias, so I can refer to it within this translation unit using AdderLambda = decltype(f(0)); int of_one(AdderLambda g) { return g(1); } int main() { auto f1 = f(1); assert(of_one(f1) == 2); auto f42 = f(42); assert(of_one(f42) == 43); }
EDITADO PARA ADICIONAR: lendo alguns de seus comentários em outras respostas, parece que você está se perguntando por quê
int add1(int x) { return x + 1; } int add2(int x) { return x + 2; } static_assert(std::is_same_v<decltype(add1), decltype(add2)>); auto add3 = [](int x) { return x + 3; }; auto add4 = [](int x) { return x + 4; }; static_assert(not std::is_same_v<decltype(add3), decltype(add4)>);
Isso porque lambdas sem captura são construtíveis por padrão. (Em C ++ apenas a partir de C ++ 20, mas sempre foi conceitualmente verdadeiro.)
template<class T> int default_construct_and_call(int x) { T t; return t(x); } assert(default_construct_and_call<decltype(add3)>(42) == 45); assert(default_construct_and_call<decltype(add4)>(42) == 46);
Se você tentasse
default_construct_and_call<decltype(&add1)>
,t
seria um ponteiro de função inicializado por padrão e provavelmente causaria um segfault. Isso não é útil.fonte
Os lambdas do C ++ precisam de tipos distintos para operações distintas, pois o C ++ se vincula estaticamente. Eles só podem ser copiados / movidos, portanto, principalmente, você não precisa nomear seu tipo. Mas tudo isso é um detalhe de implementação.
Não tenho certeza se lambdas C # têm um tipo, pois são "expressões de função anônimas" e são imediatamente convertidas em um tipo de delegado compatível ou tipo de árvore de expressão. Se sim, provavelmente é um tipo impronunciável.
C ++ também possui estruturas anônimas, onde cada definição leva a um tipo único. Aqui o nome não é impronunciável, simplesmente não existe no que diz respeito ao padrão.
C # tem tipos de dados anônimos , que proíbe cuidadosamente de escapar do escopo em que foram definidos. A implementação também dá um nome único e impronunciável a eles.
Ter um tipo anônimo indica ao programador que ele não deve mexer em sua implementação.
A parte, de lado:
Você pode dar um nome ao tipo de um lambda.
auto foo = []{}; using Foo_t = decltype(foo);
Se você não tiver nenhuma captura, pode usar um tipo de ponteiro de função
void (*pfoo)() = foo;
fonte
Foo_t = []{};
, apenasFoo_t = foo
e nada mais.Por que usar tipos anônimos?
Para os tipos gerados automaticamente pelo compilador, a escolha é (1) honrar a solicitação do usuário para o nome do tipo ou (2) deixar o compilador escolher um por conta própria.
No primeiro caso, espera-se que o usuário forneça explicitamente um nome cada vez que tal construção aparecer (C ++ / Rust: sempre que um lambda é definido; Rust: sempre que uma função é definida). Esse é um detalhe tedioso para o usuário fornecer todas as vezes e, na maioria dos casos, o nome nunca é referido novamente. Portanto, faz sentido deixar o compilador descobrir um nome para ele automaticamente e usar recursos existentes, como
decltype
inferência de tipo ou, para fazer referência ao tipo nos poucos lugares onde ele é necessário.No último caso, o compilador precisa escolher um nome exclusivo para o tipo, que provavelmente seria um nome obscuro e ilegível como
__namespace1_module1_func1_AnonymousFunction042
. O designer da linguagem poderia especificar precisamente como esse nome é construído em detalhes gloriosos e delicados, mas isso expõe desnecessariamente ao usuário um detalhe de implementação no qual nenhum usuário sensato poderia confiar, uma vez que o nome é sem dúvida frágil em face de refatoradores menores. Isso também restringe desnecessariamente a evolução da linguagem: futuras adições de recursos podem fazer com que o algoritmo de geração de nomes existente seja alterado, levando a problemas de compatibilidade com versões anteriores. Portanto, faz sentido simplesmente omitir esse detalhe e afirmar que o tipo gerado automaticamente não pode ser dito pelo usuário.Por que usar tipos únicos (distintos)?
Se um valor tiver um tipo exclusivo, um compilador de otimização pode rastrear um tipo exclusivo em todos os seus sites de uso com fidelidade garantida. Como corolário, o usuário pode ter certeza dos locais onde a proveniência desse valor específico é totalmente conhecida pelo compilador.
Por exemplo, no momento em que o compilador vê:
let f: __UniqueFunc042 = || { ... }; // definition of __UniqueFunc042 (assume it has a nontrivial closure) /* ... intervening code */ let g: __UniqueFunc042 = /* some expression */; g();
o compilador tem plena confiança que
g
deve necessariamente se originar def
, mesmo sem saber a proveniência deg
. Isso permitiria que a chamadag
fosse desvirtualizada. O usuário saberia disso também, pois o usuário tomou muito cuidado para preservar o tipo único def
através do fluxo de dados que o conduziug
.Necessariamente, isso restringe o que o usuário pode fazer
f
. O usuário não tem liberdade para escrever:let q = if some_condition { f } else { || {} }; // ERROR: type mismatch
pois isso levaria à unificação (ilegal) de dois tipos distintos.
Para contornar isso, o usuário pode fazer o upcast do
__UniqueFunc042
para o tipo não exclusivo&dyn Fn()
,let f2 = &f as &dyn Fn(); // upcast let q2 = if some_condition { f2 } else { &|| {} }; // OK
A compensação feita por este tipo de eliminação é que os usos de
&dyn Fn()
complicam o raciocínio para o compilador. Dado:let g2: &dyn Fn() = /*expression */;
o compilador deve examinar meticulosamente o
/*expression */
para determinar se seg2
origina def
ou alguma outra função (ões) e as condições sob as quais essa proveniência se mantém. Em muitas circunstâncias, o compilador pode desistir: talvez o humano possa dizer queg2
realmente vem def
em todas as situações, mas o caminho def
parag2
era muito complicado para o compilador decifrar, resultando em uma chamada virtual parag2
com desempenho pessimista.Isso se torna mais evidente quando tais objetos são entregues a funções genéricas (modelo):
fn h<F: Fn()>(f: F);
Se alguém chama
h(f)
wheref: __UniqueFunc042
, entãoh
é especializado em uma instância única:Isso permite que o compilador gere código especializado para
h
, adaptado para o argumento específico def
, e o envio paraf
é muito provável que seja estático, se não embutido.No cenário oposto, onde se chama
h(f)
comf2: &Fn()
, oh
é instanciado comoh::<&Fn()>(f);
que é compartilhado entre todas as funções do tipo
&Fn()
. De dentroh
, o compilador sabe muito pouco sobre uma função opaca do tipo&Fn()
e, portanto, só pode chamar de forma conservadoraf
com um envio virtual. Para despachar estaticamente, o compilador teria que embutir a chamadah::<&Fn()>(f)
em seu site de chamada, o que não é garantido seh
for muito complexo.fonte
void(*)(int, double)
pode não ter um nome, mas posso anotá-lo. Eu o chamaria de tipo sem nome, não de tipo anônimo. E eu chamaria coisas enigmáticas como__namespace1_module1_func1_AnonymousFunction042
mutilação de nomes, o que definitivamente não está no escopo desta questão. Esta pergunta é sobre tipos que são garantidos pelo padrão como impossíveis de escrever, em oposição à introdução de uma sintaxe de tipo que pode expressar esses tipos de uma maneira útil.Primeiro, lambda sem captura são conversíveis em um ponteiro de função. Portanto, eles fornecem alguma forma de genericidade.
Agora, por que lambdas com captura não são conversíveis em ponteiro? Como a função deve acessar o estado do lambda, esse estado precisaria aparecer como um argumento da função.
fonte
std::function<>
.Para evitar conflitos de nome com o código do usuário.
Mesmo dois lambdas com a mesma implementação terão tipos diferentes. O que não tem problema, porque também posso ter tipos diferentes de objetos, mesmo que o layout da memória seja igual.
fonte
int (*)(Foo*, int, double)
não corre o risco de colisão do nome com o código do usuário.void(*)(void)
paravoid*
C / C ++ padrão e vice-versa.