Ai sẽ đổ lỗi cho phạm vi này dựa trên một tham chiếu đến tạm thời?


15

Các mã sau đây trông khá vô hại từ cái nhìn đầu tiên. Một người dùng sử dụng chức năng bar()để tương tác với một số chức năng thư viện. (Điều này thậm chí có thể đã hoạt động trong một thời gian dài kể từ khi bar()trả về một tham chiếu đến một giá trị không tạm thời hoặc tương tự.) Tuy nhiên, bây giờ nó chỉ đơn giản là trả về một thể hiện mới của B. Bmột lần nữa có một hàm a()trả về một tham chiếu đến một đối tượng của kiểu lặp A. Người dùng muốn truy vấn đối tượng này dẫn đến một segfault vì Bđối tượng tạm thời được trả về bar()bị hủy trước khi lặp lại bắt đầu.

Tôi thiếu quyết đoán ai (thư viện hoặc người dùng) sẽ đổ lỗi cho việc này. Tất cả các thư viện cung cấp các lớp trông có vẻ sạch sẽ đối với tôi và chắc chắn không làm gì khác (trả lại các tham chiếu cho các thành viên, trả về các thể hiện ngăn xếp, ...) so với rất nhiều mã khác hiện có. Người dùng dường như cũng không làm gì sai cả, anh ta chỉ lặp đi lặp lại trên một số đối tượng mà không làm bất cứ điều gì liên quan đến các đối tượng đó suốt đời.

(Một câu hỏi liên quan có thể là: Người ta có nên thiết lập quy tắc chung rằng mã không nên "dựa trên phạm vi để lặp lại" đối với một thứ được lấy bởi nhiều hơn một cuộc gọi bị xiềng xích trong tiêu đề vòng lặp vì bất kỳ cuộc gọi nào trong số này có thể trả về giá trị?)

#include <algorithm>
#include <iostream>

// "Library code"
struct A
{
    A():
        v{0,1,2}
    {
        std::cout << "A()" << std::endl;
    }

    ~A()
    {
        std::cout << "~A()" << std::endl;
    }

    int * begin()
    {
        return &v[0];
    }

    int * end()
    {
        return &v[3];
    }

    int v[3];
};

struct B
{
    A m_a;

    A & a()
    {
        return m_a;
    }
};

B bar()
{
    return B();
}

// User code
int main()
{
    for( auto i : bar().a() )
    {
        std::cout << i << std::endl;
    }
}

6
Khi bạn tìm ra ai để đổ lỗi, bước tiếp theo sẽ là gì? La hét với anh ấy / cô ấy?
JensG

7
Không, tại sao tôi? Tôi thực sự quan tâm nhiều hơn để biết quá trình suy nghĩ phát triển "chương trình" này thất bại ở đâu để tránh vấn đề này trong tương lai.
hllnll

Điều này không liên quan gì đến các giá trị, hoặc dựa trên phạm vi cho các vòng lặp, nhưng với người dùng không hiểu đúng về tuổi thọ của đối tượng.
James

Nhận xét trang web: Đây là CWG 900 đã bị đóng là Không phải là Lỗi. Có lẽ biên bản chứa một số thảo luận.
dyp

7
Ai là người đổ lỗi cho điều này? Bjarne Stroustrup và Dennis Ritchie, đầu tiên và quan trọng nhất.
Mason Wheeler

Câu trả lời:


14

Tôi nghĩ vấn đề cơ bản là sự kết hợp các tính năng ngôn ngữ (hoặc thiếu chúng) của C ++. Cả mã thư viện và mã máy khách đều hợp lý (bằng chứng là thực tế là vấn đề không rõ ràng). Nếu thời gian tồn tại tạm thời Bđược kéo dài phù hợp (đến cuối vòng lặp) thì sẽ không có vấn đề gì.

Làm cho cuộc sống tạm thời chỉ đủ dài, và không còn, là vô cùng khó khăn. Thậm chí không phải là một quảng cáo "tất cả các thời gian liên quan đến việc tạo ra phạm vi cho một phạm vi dựa trên phạm vi để tồn tại cho đến khi kết thúc vòng lặp" sẽ không có tác dụng phụ. Xem xét trường hợp B::a()trả về một phạm vi độc lập với Bđối tượng theo giá trị. Sau đó tạm thời Bcó thể được loại bỏ ngay lập tức. Ngay cả khi người ta có thể xác định chính xác các trường hợp cần gia hạn trọn đời, vì những trường hợp này không rõ ràng đối với các lập trình viên, thì hiệu ứng (hàm hủy được gọi nhiều sau này) sẽ gây ngạc nhiên và có lẽ là một nguồn lỗi tinh vi không kém.

Sẽ tốt hơn nếu chỉ phát hiện và cấm những điều vô nghĩa đó, buộc lập trình viên phải nâng cao một cách rõ ràng bar()đến một biến cục bộ. Điều này là không thể có trong C ++ 11, và có lẽ sẽ không bao giờ có thể bởi vì nó yêu cầu chú thích. Rust thực hiện điều này, trong đó chữ ký của .a()sẽ là:

fn a<'x>(bar: &'x B) -> &'x A { bar.a }
// If we make it as explicit as possible, or
fn a(&self) -> &A { self.a }
// if we make it a method and rely on lifetime elision.

Đây 'xlà một biến hoặc khu vực trọn đời, là một tên tượng trưng cho khoảng thời gian có sẵn một tài nguyên. Thành thật mà nói, thời gian sống rất khó để giải thích - hoặc chúng tôi chưa tìm ra lời giải thích tốt nhất - vì vậy tôi sẽ giới hạn bản thân ở mức tối thiểu cần thiết cho ví dụ này và giới thiệu người đọc nghiêng về tài liệu chính thức .

Người kiểm tra khoản vay sẽ nhận thấy rằng kết quả của bar().a()nhu cầu sống miễn là vòng lặp chạy. Phrased như một hạn chế trong cuộc sống 'x, chúng tôi viết : 'loop <= 'x. Nó cũng sẽ nhận thấy rằng người nhận cuộc gọi phương thức bar(), là tạm thời. Hai con trỏ được liên kết với cùng một đời, do đó 'x <= 'templà một hạn chế khác.

Hai ràng buộc này là mâu thuẫn! Chúng tôi cần 'loop <= 'x <= 'tempnhưng 'temp <= 'loop, trong đó nắm bắt vấn đề khá chính xác. Do các yêu cầu mâu thuẫn, mã lỗi bị từ chối. Lưu ý rằng đây là kiểm tra thời gian biên dịch và mã Rust thường dẫn đến cùng mã máy với mã C ++ tương đương, do đó bạn không cần phải trả chi phí thời gian chạy cho nó.

Tuy nhiên, đây là một tính năng lớn để thêm vào một ngôn ngữ và chỉ hoạt động nếu tất cả các mã sử dụng nó. thiết kế API cũng bị ảnh hưởng (một số thiết kế quá nguy hiểm trong C ++ trở nên thiết thực, một số khác không thể được thực hiện để chơi đẹp với thời gian sống). Than ôi, điều đó có nghĩa là không thực tế khi thêm vào C ++ (hoặc bất kỳ ngôn ngữ nào thực sự) hồi tố. Tóm lại, lỗi thuộc về các ngôn ngữ thành công có quán tính và thực tế là Bjarne năm 1983 không có quả cầu pha lê và tầm nhìn xa để kết hợp các bài học của 30 năm nghiên cứu và kinh nghiệm C ++ vừa qua ;-)

Tất nhiên, điều đó hoàn toàn không hữu ích trong việc tránh vấn đề trong tương lai (trừ khi bạn chuyển sang Rust và không bao giờ sử dụng C ++ nữa). Người ta có thể tránh các biểu thức dài hơn với nhiều lệnh gọi phương thức được xâu chuỗi (khá hạn chế và thậm chí không khắc phục được tất cả các rắc rối suốt đời). Hoặc người ta có thể thử áp dụng chính sách sở hữu kỷ luật hơn mà không cần hỗ trợ trình biên dịch: Tài liệu rõ ràng bartrả về theo giá trị và kết quả của việc B::a()không được tồn tại lâu hơn so Bvới yêu a()cầu được gọi. Khi thay đổi một hàm để trả về theo giá trị thay vì tham chiếu dài hơn, hãy lưu ý rằng đây là thay đổi hợp đồng . Vẫn dễ bị lỗi, nhưng có thể tăng tốc quá trình xác định nguyên nhân khi nó xảy ra.


14

Chúng ta có thể giải quyết vấn đề này bằng các tính năng của C ++ không?

C ++ 11 đã thêm các vòng loại chức năng thành viên, cho phép hạn chế loại giá trị của thể hiện lớp (biểu thức) mà hàm thành viên có thể được gọi trên. Ví dụ:

struct foo {
    void bar() & {} // lvalue-ref-qualified
};

foo& lvalue ();
foo  prvalue();

lvalue ().bar(); // OK
prvalue().bar(); // error

Khi gọi beginhàm thành viên, chúng tôi biết rằng rất có thể chúng tôi cũng sẽ cần gọi endhàm thành viên (hoặc đại loại như size, để có được kích thước của phạm vi). Điều này đòi hỏi chúng ta phải hoạt động theo giá trị, vì chúng ta cần giải quyết nó hai lần. Do đó, bạn có thể lập luận rằng các hàm thành viên này phải đủ tiêu chuẩn.

Tuy nhiên, điều này có thể không giải quyết được vấn đề cơ bản: răng cưa. Hàm beginendthành viên bí danh đối tượng hoặc tài nguyên được quản lý bởi đối tượng. Nếu chúng ta thay thế beginendbằng một hàm duy nhất range, chúng ta nên cung cấp một hàm có thể được gọi theo giá trị:

struct foo {
    vector<int> arr;

    auto range() & // C++14 return type deduction for brevity
    { return std::make_pair(arr.begin(), arr.end()); }
};

for(auto const& e : foo().range()) // error

Đây có thể là một trường hợp sử dụng hợp lệ, nhưng định nghĩa trên rangekhông cho phép nó. Vì chúng ta không thể giải quyết tạm thời sau khi gọi hàm thành viên, nên trả về một container có nghĩa là hợp lý hơn: nghĩa là một phạm vi sở hữu:

struct foo {
    vector<int> arr;

    auto range() &
    { return std::make_pair(arr.begin(), arr.end()); }

    auto range() &&
    { return std::move(arr); }
};

for(auto const& e : foo().range()) // OK

Áp dụng điều này cho trường hợp của OP và xem xét mã nhẹ

struct B {
    A m_a;
    A & a() { return m_a; }
};

Hàm thành viên này thay đổi loại giá trị của biểu thức: B()là một B().a()giá trị , nhưng là một giá trị. Mặt khác, B().m_alà một giá trị. Vì vậy, hãy bắt đầu bằng cách làm cho điều này phù hợp. Có hai cách để làm điều này:

struct B {
    A m_a;
    A &  a() &  { return m_a; }

    A && a() && { return std::move(m_a); }
    // or
    A    a() && { return std::move(m_a); }
};

Phiên bản thứ hai, như đã nói ở trên, sẽ khắc phục sự cố trong OP.

Ngoài ra, chúng tôi có thể hạn chế Bcác chức năng thành viên:

struct A {
    // [...]

    int * begin() & { return &v[0]; }
    int * end  () & { return &v[3]; }

    int v[3];
};

Điều này sẽ không có bất kỳ tác động nào đến mã của OP, vì kết quả của biểu thức sau :vòng lặp dựa trên phạm vi được liên kết với một biến tham chiếu. Và biến này (như một biểu thức được sử dụng để truy cập các hàm beginendthành viên của nó ) là một giá trị.

Tất nhiên, câu hỏi đặt ra là liệu quy tắc mặc định có nên là "các hàm thành viên bí danh trên các giá trị sẽ trả về một đối tượng sở hữu tất cả tài nguyên của nó hay không, trừ khi có lý do chính đáng để không" . Bí danh mà nó trả về có thể được sử dụng một cách hợp pháp, nhưng nguy hiểm theo cách bạn trải nghiệm: nó không thể được sử dụng để kéo dài thời gian tồn tại của "cha mẹ" tạm thời:

// using the OP's definition of `struct B`,
// or version 1, `A && a() &&;`

A&&      a = B().a(); // bug: binds directly, dangling reference
A const& a = B().a(); // bug: same as above
A        a = B().a(); // OK

A&&      a = B().m_a; // OK: extends the lifetime of the temporary

Trong C ++ 2a, tôi nghĩ bạn phải giải quyết vấn đề này (hoặc tương tự) như sau:

for( B b = bar(); auto i : b.a() )

thay vì của OP

for( auto i : bar().a() )

Cách giải quyết theo cách thủ công xác định rằng thời gian tồn tại blà toàn bộ khối của vòng lặp for.

Đề xuất giới thiệu tuyên bố khởi xướng này

Bản thử trực tiếp


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.