Tại sao phải thiết kế một ngôn ngữ với các kiểu ẩn danh duy nhất?


91

Đây là điều luôn làm tôi khó chịu như một tính năng của biểu thức lambda C ++: Loại biểu thức lambda C ++ là duy nhất và ẩn danh, tôi chỉ đơn giản là không thể viết nó ra. Ngay cả khi tôi tạo hai lambdas hoàn toàn giống nhau về mặt cú pháp, các kiểu kết quả được xác định là khác biệt. Kết quả là, a) lambdas chỉ có thể được chuyển đến các hàm khuôn mẫu cho phép thời gian biên dịch, kiểu không xác định được truyền cùng với đối tượng và b) lambdas đó chỉ hữu ích khi chúng bị xóa thông qua std::function<>.

Ok, nhưng đó chỉ là cách C ++ thực hiện, tôi đã sẵn sàng viết nó ra chỉ như một tính năng khó chịu của ngôn ngữ đó. Tuy nhiên, tôi mới biết rằng Rust dường như cũng vậy: Mỗi hàm Rust hoặc lambda có một kiểu ẩn danh, duy nhất. Và bây giờ tôi đang tự hỏi: Tại sao?

Vì vậy, câu hỏi của tôi là: Theo
quan điểm của một nhà thiết kế ngôn ngữ, có lợi gì để giới thiệu khái niệm kiểu ẩn danh, duy nhất vào một ngôn ngữ?


6
như mọi khi câu hỏi tốt hơn là tại sao không.
Stargateur

31
"lambdas đó chỉ hữu ích khi chúng bị xóa thông qua std :: function <>" - không, chúng hữu ích trực tiếp mà không cần std::function. Một lambda đã được chuyển cho một hàm mẫu có thể được gọi trực tiếp mà không cần liên quan đến std::function. Sau đó, trình biên dịch có thể nội dòng lambda vào hàm mẫu, điều này sẽ cải thiện hiệu quả thời gian chạy.
Erlkoenig

1
Tôi đoán, nó làm cho việc đọc lambda dễ dàng hơn và làm cho ngôn ngữ dễ hiểu hơn. Nếu bạn đã cho phép cùng một biểu thức lambda được gấp lại thành cùng một kiểu, thì bạn sẽ cần các quy tắc đặc biệt để xử lý { int i = 42; auto foo = [&i](){ return i; }; } { int i = 13; auto foo = [&i](){ return i; }; }vì biến mà nó tham chiếu là khác nhau, mặc dù về văn bản chúng giống nhau. Nếu bạn chỉ nói rằng tất cả chúng là duy nhất, bạn không cần phải lo lắng về việc cố gắng tìm ra nó.
NathanOliver

5
nhưng bạn cũng có thể đặt tên cho kiểu lambdas và làm tất cả những điều tương tự với kiểu đó. lambdas_type = decltype( my_lambda);
idclev 463035818

3
Nhưng những gì nên là một loại lambda chung chung [](auto) {}? Nó nên có một loại, để bắt đầu?
Sự kiện

Câu trả lời:


78

Nhiều tiêu chuẩn (đặc biệt là C ++) áp dụng phương pháp giảm thiểu mức độ chúng yêu cầu từ các trình biên dịch. Nói thẳng ra là họ đã yêu cầu đủ rồi! Nếu họ không phải chỉ định một cái gì đó để làm cho nó hoạt động, họ có xu hướng để việc triển khai nó được xác định.

Nếu các lambdas không được ẩn danh, chúng ta sẽ phải xác định chúng. Điều này sẽ phải nói rất nhiều về cách các biến được nắm bắt. Hãy xem xét trường hợp của lambda [=](){...}. Kiểu sẽ phải chỉ định kiểu nào thực sự được lambda bắt, điều này có thể không nhỏ để xác định. Ngoài ra, điều gì sẽ xảy ra nếu trình biên dịch tối ưu hóa thành công một biến? Xem xét:

static const int i = 5;
auto f = [i]() { return i; }

Một trình biên dịch tối ưu hóa có thể dễ dàng nhận ra rằng giá trị duy nhất có thể có của giá trị iđó có thể được ghi lại là 5 và thay thế giá trị này bằng auto f = []() { return 5; }. Tuy nhiên, nếu loại không phải là ẩn danh, điều này có thể thay đổi loại hoặc buộc trình biên dịch tối ưu hóa ít hơn, lưu trữ ingay cả khi nó không thực sự cần. Đây là một túi phức tạp và sắc thái đơn giản là không cần thiết cho những gì những người lambdas dự định làm.

Và, trong trường hợp bạn thực sự cần một kiểu không ẩn danh, bạn luôn có thể tự mình xây dựng lớp đóng và làm việc với một hàm functor thay vì một hàm lambda. Do đó, họ có thể khiến lambdas xử lý trường hợp 99% và để bạn viết mã giải pháp của riêng bạn trong trường hợp 1%.


Deduplicator đã chỉ ra trong các bình luận rằng tôi không đề cập đến tính duy nhất nhiều như ẩn danh. Tôi ít chắc chắn hơn về lợi ích của tính duy nhất, nhưng điều đáng chú ý là hành vi của điều sau là rõ ràng nếu các loại là duy nhất (hành động sẽ được khởi tạo hai lần).

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; });

Nếu các loại không phải là duy nhất, chúng tôi sẽ phải chỉ định hành vi nào sẽ xảy ra trong trường hợp này. Đó có thể là khó khăn. Một số vấn đề được đưa ra về chủ đề ẩn danh cũng nâng cao cái đầu xấu xí của họ trong trường hợp này vì sự độc đáo.


Lưu ý rằng đây không thực sự là tiết kiệm công việc cho người triển khai trình biên dịch, mà là tiết kiệm công việc cho người duy trì tiêu chuẩn. Trình biên dịch vẫn phải trả lời tất cả các câu hỏi ở trên để triển khai cụ thể, nhưng chúng không được chỉ định trong tiêu chuẩn.
ComicSansMS

2
@ComicSansMS Kết hợp những thứ như vậy lại với nhau khi triển khai trình biên dịch sẽ dễ dàng hơn nhiều khi bạn không phải điều chỉnh việc triển khai của mình theo tiêu chuẩn của người khác. Nói từ kinh nghiệm, nó thường là xa dễ dàng hơn trên một nhà bảo trì các tiêu chuẩn để chức năng overspecify hơn là để cố gắng tìm ra số tiền tối thiểu để xác định trong khi vẫn nhận được chức năng mong muốn ra khỏi ngôn ngữ của bạn. Là một nghiên cứu điển hình xuất sắc, hãy xem họ đã bỏ ra bao nhiêu công để tránh ghi rõ memory_order_consume trong khi vẫn làm cho nó hữu ích (trên một số kiến ​​trúc)
Cort Ammon

1
Giống như những người khác, bạn tạo ra một trường hợp hấp dẫn cho việc ẩn danh . Nhưng liệu nó có thực sự là một ý tưởng hay khi buộc nó phải là duy nhất ?
Deduplicator

Vấn đề ở đây không phải là độ phức tạp của trình biên dịch mà là độ phức tạp của mã được tạo. Vấn đề không phải là làm cho trình biên dịch đơn giản hơn, mà là cung cấp cho nó đủ chỗ để tối ưu hóa tất cả các trường hợp và tạo ra mã tự nhiên cho nền tảng đích.
Jan Hudec

Bạn không thể nắm bắt một biến tĩnh.
Ruslan

70

Lambdas không chỉ là các hàm, chúng còn là một hàm và một trạng thái . Do đó, cả C ++ và Rust đều triển khai chúng như một đối tượng với toán tử cuộc gọi ( operator()trong C ++, 3 Fn*đặc điểm trong Rust).

Về cơ bản, [a] { return a + 1; }trong C ++ giải mã thành một thứ gì đó như

struct __SomeName {
    int a;

    int operator()() {
        return a + 1;
    }
};

sau đó sử dụng một phiên bản của __SomeNamelambda được sử dụng.

Trong khi ở trong Rust, || a + 1trong Rust sẽ giải thoát khỏi cái gì đó như

{
    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 }
}

Điều này có nghĩa là hầu hết các lambdas phảinhiều loại khác nhau.

Bây giờ, có một số cách chúng ta có thể làm điều đó:

  • Với các kiểu ẩn danh, đó là điều mà cả hai ngôn ngữ đều triển khai. Một hệ quả khác của điều đó là tất cả lambdas phải có một loại khác nhau. Nhưng đối với các nhà thiết kế ngôn ngữ, điều này có một lợi thế rõ ràng: Lambdas có thể được mô tả đơn giản bằng cách sử dụng các phần đơn giản hơn đã tồn tại của ngôn ngữ. Chúng chỉ là đường cú pháp xung quanh các bit đã tồn tại của ngôn ngữ.
  • Với một số cú pháp đặc biệt để đặt tên cho các kiểu lambda: Tuy nhiên, điều này là không cần thiết vì lambda đã có thể được sử dụng với các mẫu trong C ++ hoặc với generic và các Fn*đặc điểm trong Rust. Không ngôn ngữ nào bắt bạn phải gõ xóa lambdas để sử dụng chúng (với std::functiontrong C ++ hoặc Box<Fn*>trong Rust).

Cũng lưu ý rằng cả hai ngôn ngữ đều đồng ý rằng các lambdas tầm thường không nắm bắt được ngữ cảnh có thể được chuyển đổi thành con trỏ hàm.


Việc mô tả các tính năng phức tạp của một ngôn ngữ bằng tính năng đơn giản hơn là khá phổ biến. Ví dụ, cả C ++ và Rust đều có các vòng lặp phạm vi cho và cả hai đều mô tả chúng như một đường cú pháp cho các tính năng khác.

C ++ định nghĩa

for (auto&& [first,second] : mymap) {
    // use first and second
}

tương đương với

{

    init-statement
    auto && __range = range_expression ;
    auto __begin = begin_expr ;
    auto __end = end_expr ;
    for ( ; __begin != __end; ++__begin) {

        range_declaration = *__begin;
        loop_statement

    }

} 

và Rust định nghĩa

for <pat> in <head> { <body> }

tương đương với

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>);
        }
    }
};

mặc dù chúng có vẻ phức tạp hơn đối với con người, nhưng lại đơn giản hơn đối với người thiết kế ngôn ngữ hoặc trình biên dịch.


15
@ cmaster-reinstatemonica Hãy xem xét việc chuyển lambda làm đối số so sánh cho một hàm sắp xếp. Bạn có thực sự muốn áp đặt chi phí gọi hàm ảo ở đây không?
Daniel Langr

5
@ cmaster-reinstatemonica vì không có gì là ảo theo mặc định trong C ++
Caleth

4
@cmaster - Ý bạn là buộc tất cả người dùng lambdas trả tiền cho dipatch động, ngay cả khi họ không cần nó?
StoryTeller - Unslander Monica

4
@ cmaster-reinstatemonica Điều tốt nhất bạn sẽ nhận được là chọn tham gia ảo. Đoán xem, std::functionhiện điều đó
Caleth

9
@ cmaster-reinstatemonica bất kỳ cơ chế nào mà bạn có thể trỏ lại hàm được gọi sẽ có các tình huống với chi phí thời gian chạy. Đó không phải là cách C ++. Bạn chọn tham gia vớistd::function
Caleth

13

(Thêm vào câu trả lời của Caleth, nhưng quá dài để đưa vào một nhận xét.)

Biểu thức lambda chỉ là đường cú pháp cho một cấu trúc ẩn danh (một kiểu Voldemort, vì bạn không thể nói tên của nó).

Bạn có thể thấy sự giống nhau giữa cấu trúc ẩn danh và ẩn danh của lambda trong đoạn mã này:

#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";
}

Nếu điều đó vẫn không thỏa mãn đối với lambda, thì nó cũng sẽ không hài lòng đối với cấu trúc ẩn danh.

Một số ngôn ngữ cho phép kiểu gõ vịt linh hoạt hơn một chút và mặc dù C ++ có các mẫu không thực sự giúp tạo một đối tượng từ mẫu có trường thành viên có thể thay thế trực tiếp lambda thay vì sử dụng std::functionvỏ bánh.


3
Cảm ơn bạn, điều đó thực sự làm sáng tỏ một chút lý do đằng sau cách các lambdas được định nghĩa trong C ++ (tôi phải nhớ thuật ngữ "kiểu Voldemort" :-)). Tuy nhiên, câu hỏi vẫn là: Lợi thế của điều này trong con mắt của một nhà thiết kế ngôn ngữ là gì?
cmaster - phục hồi monica

1
Bạn thậm chí có thể thêm int& operator()(){ return x; }vào các cấu trúc đó
Caleth

2
@ cmaster-reinstatemonica • Nói rõ ràng ... phần còn lại của C ++ hoạt động theo cách đó. Để làm cho lambdas sử dụng một số kiểu gõ vịt "hình dạng bề mặt" sẽ là một cái gì đó rất khác so với phần còn lại của ngôn ngữ. Việc thêm loại cơ sở đó vào ngôn ngữ cho lambdas có thể sẽ được coi là phổ biến cho toàn bộ ngôn ngữ và đó sẽ là một thay đổi có khả năng mang tính đột phá lớn. Việc bỏ qua một cơ sở như vậy cho chỉ lambdas phù hợp với cách gõ mạnh mẽ của phần còn lại của C ++.
Eljay

Về mặt kỹ thuật, một loại Voldemort sẽ là auto foo(){ struct DarkLord {} tom_riddle; return tom_riddle; }, bởi vì bên ngoài fookhông có gì có thể sử dụng số nhận dạngDarkLord
Caleth

@ cmaster-reinstatemonica hiệu quả, giải pháp thay thế sẽ là đóng hộp & gửi động mọi lambda (phân bổ nó trên heap và xóa loại chính xác của nó). Bây giờ, như bạn lưu ý rằng trình biên dịch có thể loại bỏ trùng lặp các loại lambdas ẩn danh, nhưng bạn vẫn không thể viết chúng ra và nó sẽ yêu cầu công việc đáng kể để đạt được rất ít, vì vậy tỷ lệ cược không thực sự có lợi.
Masklinn

10

Tại sao phải thiết kế một ngôn ngữ với các kiểu ẩn danh duy nhất ?

Bởi vì có những trường hợp tên không liên quan và không hữu ích hoặc thậm chí phản tác dụng. Trong trường hợp này, khả năng trừu tượng hóa sự tồn tại của chúng rất hữu ích vì nó làm giảm ô nhiễm tên và giải quyết một trong hai vấn đề khó khăn trong khoa học máy tính (cách đặt tên cho mọi thứ). Vì lý do tương tự, các đối tượng tạm thời rất hữu ích.

lambda

Tính duy nhất không phải là điều lambda đặc biệt, hoặc thậm chí là điều đặc biệt đối với các loại ẩn danh. Nó cũng áp dụng cho các loại được đặt tên trong ngôn ngữ. Hãy xem xét những điều sau:

struct A {
    void operator()(){};
};

struct B {
    void operator()(){};
};

void foo(A);

Lưu ý rằng tôi không thể chuyển Bvào foo, mặc dù các lớp giống hệt nhau. Thuộc tính tương tự này áp dụng cho các loại không có tên.

lambdas chỉ có thể được truyền cho các hàm khuôn mẫu cho phép chuyển thời gian biên dịch, kiểu không xác định được cùng với đối tượng ... bị xóa thông qua std :: function <>.

Có một tùy chọn thứ ba cho một tập con lambdas: Các lambdas không chụp có thể được chuyển đổi thành con trỏ hàm.


Lưu ý rằng nếu các hạn chế của kiểu ẩn danh là một vấn đề đối với trường hợp sử dụng, thì giải pháp rất đơn giản: Có thể sử dụng kiểu được đặt tên thay thế. Lambdas không làm bất cứ điều gì không thể thực hiện được với một lớp được đặt tên.


10

Câu trả lời được chấp nhận của Cort Ammon là tốt, nhưng tôi nghĩ rằng có một điểm quan trọng hơn cần làm về khả năng triển khai.

Giả sử tôi có hai đơn vị dịch khác nhau, "one.cpp" và "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);

Hai quá tải foosử dụng cùng một mã định danh ( foo) nhưng có tên khác nhau. (Trong Itanium ABI được sử dụng trên hệ thống POSIX-ish, các tên bị xáo trộn _Z3foo1Avà trong trường hợp cụ thể này là _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) {}

Trình biên dịch C ++ phải đảm bảo rằng tên bị void foo(A1)xáo trộn của trong "two.cpp" giống với tên có dấu của extern void foo(A2)trong "one.cpp", để chúng ta có thể liên kết hai tệp đối tượng với nhau. Đây là ý nghĩa vật lý của hai loại là "cùng một loại": về cơ bản nó là về khả năng tương thích ABI giữa các tệp đối tượng được biên dịch riêng biệt.

Trình biên dịch C ++ không bắt buộc phải đảm bảo rằng B1B2là "cùng một loại." (Trên thực tế, cần phải đảm bảo rằng chúng là các loại khác nhau; nhưng điều đó không quan trọng ngay bây giờ.)


Trình biên dịch sử dụng cơ chế vật lý nào để đảm bảo điều đó A1A2là "cùng một kiểu"?

Nó chỉ đơn giản là đào qua các typedef, và sau đó xem xét tên đầy đủ của loại. Đó là một loại lớp được đặt tên A. (Chà, ::Avì nó nằm trong không gian tên chung.) Vì vậy, nó là cùng một kiểu trong cả hai trường hợp. Điều đó dễ hiểu. Quan trọng hơn, nó dễ thực hiện . Để xem hai loại lớp có phải là cùng một loại hay không, bạn lấy tên của chúng và thực hiện a strcmp. Để biến một loại lớp thành tên bị xáo trộn của một hàm, bạn viết số ký tự trong tên của nó, sau đó là các ký tự đó.

Vì vậy, các loại được đặt tên rất dễ nhầm lẫn.

Cơ chế vật lý nào mà trình biên dịch có thể sử dụng để đảm bảo rằng B1B2là "cùng một kiểu", trong một thế giới giả định nơi C ++ yêu cầu chúng phải cùng kiểu?

Chà, nó không thể sử dụng tên của loại, bởi vì loại không tên.

Có thể bằng cách nào đó nó có thể mã hóa văn bản của phần thân lambda. Nhưng điều đó sẽ hơi khó xử, bởi vì thực sự btrong "one.cpp" khác biệt một cách tinh tế với btrong "two.cpp": "one.cpp" có x+1và "hai.cpp" có x + 1. Vì vậy, chúng tôi sẽ phải đưa ra một quy tắc nói rằng sự khác biệt về khoảng trắng này không quan trọng, hoặc nó (làm cho chúng trở thành các kiểu khác nhau), hoặc có thể nó có (có thể tính hợp lệ của chương trình được xác định bởi triển khai hoặc có thể là "không chuẩn xác không cần chẩn đoán"). Dù sao,A

Cách đơn giản nhất để thoát khỏi khó khăn là nói rằng mỗi biểu thức lambda tạo ra các giá trị của một kiểu duy nhất. Khi đó, hai kiểu lambda được xác định trong các đơn vị dịch khác nhau chắc chắn không phải là cùng một kiểu . Trong một đơn vị dịch duy nhất, chúng ta có thể "đặt tên" cho các loại lambda chỉ bằng cách đếm từ đầu mã nguồn:

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
} 

Tất nhiên những cái tên này chỉ có ý nghĩa trong đơn vị dịch thuật này. TU $_0này luôn là một loại khác với một số TU khác $_0, mặc dù TU struct Anày luôn là cùng một loại với một số TU khác struct A.

Nhân tiện, hãy lưu ý rằng ý tưởng "mã hóa văn bản của lambda" của chúng tôi có một vấn đề nhỏ khác: lambda $_2$_3bao gồm chính xác cùng một văn bản , nhưng rõ ràng chúng không được coi là cùng một loại!


Nhân tiện, C ++ yêu cầu trình biên dịch biết cách xử lý văn bản của một biểu thức C ++ tùy ý , như trong

template<class T> void foo(decltype(T())) {}
template void foo<int>(int);  // _Z3fooIiEvDTcvT__EE, not _Z3fooIiEvT_

Nhưng C ++ không (chưa) yêu cầu trình biên dịch biết cách xử lý một câu lệnh C ++ tùy ý . decltype([](){ ...arbitrary statements... })vẫn chưa được định hình ngay cả trong C ++ 20.


Cũng thông báo rằng thật dễ dàng để đưa ra một bí danh địa phương để một kiểu giấu tên sử dụng typedef/ using. Tôi có cảm giác rằng câu hỏi của bạn có thể nảy sinh từ việc cố gắng làm điều gì đó có thể được giải quyết như thế này.

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);
}

CHỈNH SỬA ĐỂ THÊM: Khi đọc một số nhận xét của bạn về các câu trả lời khác, có vẻ như bạn đang tự hỏi tại sao

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)>);

Đó là bởi vì lambdas không chụp được mặc định có thể xây dựng. (Chỉ trong C ++ và C ++ 20, nhưng nó luôn đúng về mặt khái niệm .)

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);

Nếu bạn đã thử default_construct_and_call<decltype(&add1)>, đây tsẽ là một con trỏ hàm được khởi tạo mặc định và bạn có thể sẽ mặc định. Điều đó, giống như, không hữu ích.


" Thực tế, cần phải đảm bảo rằng chúng là các kiểu khác nhau; nhưng điều đó không quan trọng ngay bây giờ. " Tôi tự hỏi liệu có lý do chính đáng để buộc tính duy nhất nếu được định nghĩa tương đương hay không.
Deduplicator

Cá nhân tôi nghĩ rằng hành vi được xác định hoàn toàn (hầu như?) Luôn tốt hơn hành vi không xác định. "Hai con trỏ hàm này có bằng nhau không? Chà, chỉ khi hai phần khởi tạo mẫu này là cùng một hàm, điều này chỉ đúng nếu hai kiểu lambda này là cùng một kiểu, chỉ đúng khi trình biên dịch quyết định hợp nhất chúng." Chà! (Tuy nhiên, thông báo rằng chúng ta có một chính xác tình hình tương tự với việc sáp nhập chuỗi-đen, và không ai được nhiễu loạn về tình hình Vì vậy, tôi nghi ngờ nó sẽ là thảm họa cho phép trình biên dịch sáp nhập các loại giống hệt nhau..)
Quuxplusone

Chà, liệu hai hàm tương đương (chặn như thể) có thể giống hệt nhau hay không cũng là một câu hỏi hay. Ngôn ngữ trong tiêu chuẩn không hoàn toàn rõ ràng cho các hàm miễn phí và / hoặc tĩnh. Nhưng đó là ngoài phạm vi ở đây.
Deduplicator

Thật tình cờ, đã có cuộc thảo luận trong tháng này trên danh sách gửi thư LLVM về việc hợp nhất các chức năng. Codegen của Clang sẽ khiến các hàm có các phần hoàn toàn trống được hợp nhất gần như "ngẫu nhiên": godbolt.org/z/obT55b Điều này không phù hợp về mặt kỹ thuật và tôi nghĩ rằng họ có thể sẽ vá LLVM để ngừng làm việc này. Nhưng vâng, đồng ý, hợp nhất các địa chỉ hàm cũng là một điều.
Quuxplusone

Ví dụ đó có các vấn đề khác, đó là thiếu câu lệnh trả về. Không phải họ đã làm cho mã không phù hợp? Ngoài ra, tôi sẽ tìm kiếm cuộc thảo luận, nhưng họ đã chỉ ra hoặc cho rằng việc hợp nhất các chức năng tương đương là không phù hợp với tiêu chuẩn, hành vi được ghi lại của họ, thành gcc, hay chỉ là một số dựa trên nó không xảy ra?
Deduplicator

9

Các lambdas C ++ cần các kiểu riêng biệt cho các hoạt động riêng biệt, vì C ++ liên kết tĩnh. Chúng chỉ có thể sao chép / di chuyển-cấu trúc, vì vậy hầu như bạn không cần đặt tên cho loại của chúng. Nhưng đó là một phần chi tiết triển khai.

Tôi không chắc liệu C # lambdas có kiểu hay không, vì chúng là "biểu thức hàm ẩn danh" và chúng ngay lập tức được chuyển đổi thành kiểu đại biểu hoặc kiểu cây biểu thức tương thích. Nếu có, nó có thể là một loại không thể phát âm.

C ++ cũng có cấu trúc ẩn danh, trong đó mỗi định nghĩa dẫn đến một kiểu duy nhất. Ở đây tên không phải là không thể phát âm, nó chỉ đơn giản là không tồn tại theo tiêu chuẩn có liên quan.

C # có các kiểu dữ liệu ẩn danh , mà nó cẩn thận cấm thoát khỏi phạm vi mà chúng được xác định. Việc triển khai cũng mang lại một cái tên duy nhất, không thể phát âm cho những người đó.

Có một kiểu ẩn danh báo hiệu cho lập trình viên rằng họ không nên tìm hiểu kỹ bên trong quá trình triển khai của họ.

Qua một bên:

Bạn có thể đặt tên cho kiểu lambda.

auto foo = []{}; 
using Foo_t = decltype(foo);

Nếu bạn không có bất kỳ ảnh chụp nào, bạn có thể sử dụng loại con trỏ hàm

void (*pfoo)() = foo;

1
Mã ví dụ đầu tiên vẫn không cho phép mã tiếp theo Foo_t = []{};, duy nhất Foo_t = foovà không có gì khác.
cmaster - phục hồi monica

1
@ cmaster-reinstatemonica đó là vì kiểu không thể tạo mặc định, không phải vì ẩn danh. Tôi đoán là điều đó liên quan nhiều đến việc tránh có một tập hợp các trường hợp góc thậm chí lớn hơn mà bạn phải nhớ, vì bất kỳ lý do kỹ thuật nào.
Caleth

6

Tại sao lại sử dụng loại ẩn danh?

Đối với các kiểu được tạo tự động bởi trình biên dịch, lựa chọn là (1) đáp ứng yêu cầu của người dùng đối với tên của kiểu hoặc (2) để trình biên dịch tự chọn một kiểu.

  1. Trong trường hợp trước, người dùng phải cung cấp tên một cách rõ ràng mỗi khi một cấu trúc như vậy xuất hiện (C ++ / Rust: bất cứ khi nào một lambda được xác định; Rust: bất cứ khi nào một hàm được xác định). Đây là một chi tiết tẻ nhạt mà người dùng phải cung cấp mỗi lần và trong phần lớn các trường hợp, tên này không bao giờ được nhắc đến nữa. Vì vậy, sẽ rất hợp lý khi để trình biên dịch tự động tìm ra tên cho nó và sử dụng các tính năng hiện có như decltypehoặc kiểu suy luận để tham chiếu kiểu ở một vài nơi cần thiết.

  2. Trong trường hợp sau, trình biên dịch cần chọn một tên duy nhất cho kiểu, có thể là một tên khó hiểu, khó đọc chẳng hạn như __namespace1_module1_func1_AnonymousFunction042. Nhà thiết kế ngôn ngữ có thể chỉ định chính xác cách cái tên này được xây dựng theo từng chi tiết đẹp đẽ và tinh tế, nhưng điều này không cần thiết để lộ chi tiết triển khai cho người dùng mà không người dùng nhạy cảm nào có thể dựa vào, vì tên này chắc chắn sẽ dễ vỡ khi đối mặt với những nhà tái cấu trúc dù là nhỏ. Điều này cũng hạn chế sự phát triển của ngôn ngữ một cách không cần thiết: các bổ sung tính năng trong tương lai có thể khiến thuật toán tạo tên hiện tại thay đổi, dẫn đến các vấn đề tương thích ngược. Do đó, sẽ hợp lý nếu chỉ cần bỏ qua chi tiết này và khẳng định rằng kiểu được tạo tự động là người dùng không thể kiểm soát được.

Tại sao lại sử dụng các kiểu duy nhất (khác biệt)?

Nếu một giá trị có một kiểu duy nhất, thì trình biên dịch tối ưu hóa có thể theo dõi một kiểu duy nhất trên tất cả các trang web sử dụng của nó với độ trung thực được đảm bảo. Như một hệ quả tất yếu, người dùng sau đó có thể chắc chắn về những nơi mà nguồn gốc của giá trị cụ thể này được trình biên dịch biết đầy đủ.

Ví dụ, thời điểm trình biên dịch thấy:

let f: __UniqueFunc042 = || { ... };  // definition of __UniqueFunc042 (assume it has a nontrivial closure)

/* ... intervening code */

let g: __UniqueFunc042 = /* some expression */;
g();

trình biên dịch có đầy đủ độ tin cậy mà gnhất thiết phải có nguồn gốc f, mà không cần biết xuất xứ của g. Điều này sẽ cho phép cuộc gọi gđược thực hiện. Người dùng cũng sẽ biết điều này, vì người dùng đã hết sức cẩn thận để duy trì kiểu duy nhất của fluồng dữ liệu dẫn đến g.

Nhất thiết, điều này hạn chế những gì người dùng có thể làm với f. Người dùng không có quyền viết:

let q = if some_condition { f } else { || {} };  // ERROR: type mismatch

vì điều đó sẽ dẫn đến sự hợp nhất (bất hợp pháp) của hai loại riêng biệt.

Để giải quyết vấn đề này, người dùng có thể đưa __UniqueFunc042lên loại không phải là duy nhất &dyn Fn(),

let f2 = &f as &dyn Fn();  // upcast
let q2 = if some_condition { f2 } else { &|| {} };  // OK

Sự đánh đổi được thực hiện bởi kiểu tẩy xóa này là việc sử dụng &dyn Fn()phức tạp hóa lý luận cho trình biên dịch. Được:

let g2: &dyn Fn() = /*expression */;

trình biên dịch phải cẩn thận kiểm tra /*expression */để xác định xem g2nguồn gốc từ fhoặc một số chức năng khác, và các điều kiện mà xuất xứ đó có. Trong nhiều trường hợp, trình biên dịch có thể bỏ cuộc: có lẽ con người có thể biết điều đó g2thực sự xuất phát ftrong mọi tình huống nhưng đường dẫn fđến g2quá phức tạp để trình biên dịch có thể giải mã, dẫn đến một cuộc gọi ảo g2với hiệu suất bi quan.

Điều này trở nên rõ ràng hơn khi các đối tượng như vậy được phân phối đến các hàm chung (mẫu):

fn h<F: Fn()>(f: F);

Nếu một người gọi h(f)where f: __UniqueFunc042, thì hđược chuyên biệt hóa cho một phiên bản duy nhất:

h::<__UniqueFunc042>(f);

Điều này cho phép trình biên dịch tạo ra mã chuyên biệt cho h, được điều chỉnh cho đối số cụ thể của f, và việc gửi đến fkhá có thể là tĩnh, nếu không có nội tuyến.

Trong trường hợp ngược lại, khi một người gọi h(f)với f2: &Fn(), hnó được khởi tạo như

h::<&Fn()>(f);

được chia sẻ giữa tất cả các chức năng của loại &Fn(). Từ bên trong h, trình biên dịch biết rất ít về một hàm không rõ ràng của kiểu &Fn()và vì vậy chỉ có thể gọi fmột cách thận trọng bằng một công văn ảo. Để gửi tĩnh, trình biên dịch sẽ phải nội tuyến cuộc gọi đến h::<&Fn()>(f)tại địa chỉ cuộc gọi của nó, điều này không được đảm bảo nếu hquá phức tạp.


Phần đầu tiên về việc chọn tên thiếu điểm: Một kiểu như void(*)(int, double)có thể không có tên, nhưng tôi có thể viết ra. Tôi sẽ gọi nó là một loại không tên, không phải một loại ẩn danh. Và tôi sẽ gọi những thứ khó hiểu như __namespace1_module1_func1_AnonymousFunction042tên mangling, chắc chắn không nằm trong phạm vi của câu hỏi này. Câu hỏi này là về các kiểu được đảm bảo bởi tiêu chuẩn là không thể viết ra, trái ngược với việc giới thiệu một cú pháp kiểu có thể diễn đạt các kiểu này một cách hữu ích.
cmaster - phục hồi monica

3

Đầu tiên, lambda không có capture có thể chuyển đổi thành một con trỏ hàm. Vì vậy, họ cung cấp một số hình thức chung chung.

Tại sao lambdas với capture không thể chuyển đổi thành pointer? Bởi vì hàm phải truy cập trạng thái của lambda, vì vậy trạng thái này sẽ cần phải xuất hiện như một đối số của hàm.


Chà, ảnh chụp nên trở thành một phần của chính lambda, phải không? Giống như chúng được gói gọn trong một std::function<>.
cmaster - phục hồi monica

3

Để tránh va chạm tên với mã người dùng.

Ngay cả hai lambdas có cùng cách triển khai cũng sẽ có các kiểu khác nhau. Điều này không sao cả vì tôi cũng có thể có nhiều kiểu khác nhau cho các đối tượng ngay cả khi bố cục bộ nhớ của chúng bằng nhau.


Loại like int (*)(Foo*, int, double)không có bất kỳ rủi ro nào về xung đột tên với mã người dùng.
cmaster - phục hồi monica

Ví dụ của bạn không khái quát rất tốt. Trong khi một biểu thức lambda chỉ là cú pháp, nó sẽ đánh giá một số cấu trúc, đặc biệt là với mệnh đề nắm bắt. Đặt tên nó một cách rõ ràng có thể dẫn đến xung đột tên của các cấu trúc đã tồn tại.
Diepvil,

Một lần nữa, câu hỏi này là về thiết kế ngôn ngữ, không phải về C ++. Tôi chắc chắn có thể xác định một ngôn ngữ mà kiểu của lambda giống với kiểu con trỏ hàm hơn là kiểu cấu trúc dữ liệu. Cú pháp con trỏ hàm trong C ++ và cú pháp kiểu mảng động trong C chứng minh rằng điều này là có thể. Và điều đó đặt ra câu hỏi, tại sao lambdas không sử dụng cách tiếp cận tương tự?
cmaster - phục hồi monica

1
Không, bạn không thể, bởi vì cách biến tấu (bắt). Bạn cần cả một hàm và dữ liệu để làm cho nó hoạt động.
Blindy

@Blindy Ồ, vâng, tôi có thể. Tôi có thể định nghĩa lambda là một đối tượng chứa hai con trỏ, một cho đối tượng chụp và một cho mã. Một đối tượng lambda như vậy sẽ dễ dàng chuyển giá trị. Hoặc tôi có thể kéo các thủ thuật với một đoạn mã ở đầu đối tượng bắt lấy địa chỉ của chính nó trước khi chuyển sang mã lambda thực. Điều đó sẽ biến một con trỏ lambda thành một địa chỉ duy nhất. Nhưng điều đó là không cần thiết vì nền tảng PPC đã chứng minh: Trên PPC, một con trỏ hàm thực sự là một cặp con trỏ. Đó là lý do tại sao bạn không thể truyền void(*)(void)tới void*và lùi trong C / C ++ tiêu chuẩn.
cmaster - phục hồi monica
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.