RAII và con trỏ thông minh trong C ++


193

Trong thực tế với C ++, RAII là gì , con trỏ thông minh là gì , chúng được triển khai như thế nào trong một chương trình và lợi ích của việc sử dụng RAII với con trỏ thông minh là gì?

Câu trả lời:


317

Một ví dụ đơn giản (và có lẽ được sử dụng quá mức) của RAII là một lớp Tệp. Không có RAII, mã có thể trông giống như thế này:

File file("/path/to/file");
// Do stuff with file
file.close();

Nói cách khác, chúng tôi phải đảm bảo rằng chúng tôi đóng tệp sau khi hoàn thành. Điều này có hai nhược điểm - thứ nhất, bất cứ nơi nào chúng tôi sử dụng Tệp, chúng tôi sẽ phải gọi Tệp :: close () - nếu chúng tôi quên làm điều này, chúng tôi sẽ giữ tệp lâu hơn chúng tôi cần. Vấn đề thứ hai là nếu một ngoại lệ được ném ra trước khi chúng ta đóng tệp?

Java giải quyết vấn đề thứ hai bằng cách sử dụng mệnh đề cuối cùng:

try {
    File file = new File("/path/to/file");
    // Do stuff with file
} finally {
    file.close();
}

hoặc kể từ Java 7, một tuyên bố thử với tài nguyên:

try (File file = new File("/path/to/file")) {
   // Do stuff with file
}

C ++ giải quyết cả hai vấn đề khi sử dụng RAII - nghĩa là đóng tệp trong hàm hủy của Tệp. Miễn là đối tượng Tệp bị hủy vào đúng thời điểm (dù sao nó cũng phải như vậy), việc đóng tệp sẽ được chúng tôi quan tâm. Vì vậy, mã của chúng tôi bây giờ trông giống như:

File file("/path/to/file");
// Do stuff with file
// No need to close it - destructor will do that for us

Điều này không thể được thực hiện trong Java vì không có gì đảm bảo khi nào đối tượng sẽ bị hủy, vì vậy chúng tôi không thể đảm bảo khi nào một tài nguyên như tệp sẽ được giải phóng.

Lên con trỏ thông minh - rất nhiều thời gian, chúng ta chỉ cần tạo các đối tượng trên ngăn xếp. Ví dụ (và lấy cắp một ví dụ từ câu trả lời khác):

void foo() {
    std::string str;
    // Do cool things to or using str
}

Điều này hoạt động tốt - nhưng nếu chúng ta muốn trả về str thì sao? Chúng ta có thể viết điều này:

std::string foo() {
    std::string str;
    // Do cool things to or using str
    return str;
}

Vì vậy, những gì sai với điều đó? Chà, kiểu trả về là std :: string - vì vậy nó có nghĩa là chúng ta trả về theo giá trị. Điều này có nghĩa là chúng tôi sao chép str và thực sự trả lại bản sao. Điều này có thể tốn kém, và chúng tôi có thể muốn tránh chi phí sao chép nó. Do đó, chúng tôi có thể nảy ra ý tưởng quay lại bằng tham chiếu hoặc bằng con trỏ.

std::string* foo() {
    std::string str;
    // Do cool things to or using str
    return &str;
}

Thật không may, mã này không hoạt động. Chúng tôi đang trả lại một con trỏ cho str - nhưng str đã được tạo trên ngăn xếp, vì vậy chúng tôi sẽ bị xóa sau khi thoát khỏi foo (). Nói cách khác, vào thời điểm người gọi nhận được con trỏ, nó vô dụng (và tệ hơn là vô dụng vì sử dụng nó có thể gây ra tất cả các loại lỗi thú vị)

Vậy, giải pháp là gì? Chúng ta có thể tạo str trên heap bằng cách sử dụng mới - theo cách đó, khi foo () hoàn thành, str sẽ không bị phá hủy.

std::string* foo() {
    std::string* str = new std::string();
    // Do cool things to or using str
    return str;
}

Tất nhiên, giải pháp này cũng không hoàn hảo. Lý do là chúng tôi đã tạo str, nhưng chúng tôi không bao giờ xóa nó. Điều này có thể không phải là một vấn đề trong một chương trình rất nhỏ, nhưng nói chung, chúng tôi muốn đảm bảo rằng chúng tôi xóa nó. Chúng ta chỉ có thể nói rằng người gọi phải xóa đối tượng sau khi anh ta kết thúc nó. Nhược điểm là người gọi phải quản lý bộ nhớ, điều này làm tăng thêm độ phức tạp và có thể bị sai, dẫn đến rò rỉ bộ nhớ tức là không xóa đối tượng mặc dù không còn cần thiết nữa.

Đây là nơi con trỏ thông minh xuất hiện. Ví dụ sau sử dụng shared_ptr - Tôi khuyên bạn nên xem các loại con trỏ thông minh khác nhau để tìm hiểu những gì bạn thực sự muốn sử dụng.

shared_ptr<std::string> foo() {
    shared_ptr<std::string> str = new std::string();
    // Do cool things to or using str
    return str;
}

Bây giờ, shared_ptr sẽ đếm số lượng tham chiếu đến str. Ví dụ

shared_ptr<std::string> str = foo();
shared_ptr<std::string> str2 = str;

Bây giờ có hai tham chiếu đến cùng một chuỗi. Một khi không còn tham chiếu đến str, nó sẽ bị xóa. Như vậy, bạn không còn phải lo lắng về việc tự xóa nó.

Chỉnh sửa nhanh: như một số ý kiến ​​đã chỉ ra, ví dụ này không hoàn hảo vì (ít nhất là!) Hai lý do. Thứ nhất, do việc thực hiện các chuỗi, sao chép một chuỗi có xu hướng không tốn kém. Thứ hai, do những gì được gọi là tối ưu hóa giá trị trả về được đặt tên, trả về theo giá trị có thể không tốn kém vì trình biên dịch có thể thực hiện một số thông minh để tăng tốc mọi thứ.

Vì vậy, hãy thử một ví dụ khác bằng cách sử dụng lớp Tệp của chúng tôi.

Giả sử chúng ta muốn sử dụng một tệp làm nhật ký. Điều này có nghĩa là chúng tôi muốn mở tệp của mình ở chế độ chỉ nối thêm:

File file("/path/to/file", File::append);
// The exact semantics of this aren't really important,
// just that we've got a file to be used as a log

Bây giờ, hãy đặt tệp của chúng tôi làm nhật ký cho một vài đối tượng khác:

void setLog(const Foo & foo, const Bar & bar) {
    File file("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Thật không may, ví dụ này kết thúc khủng khiếp - tệp sẽ bị đóng ngay khi phương thức này kết thúc, có nghĩa là foo và thanh hiện có tệp nhật ký không hợp lệ. Chúng ta có thể xây dựng tệp trên heap và chuyển một con trỏ tới tệp cho cả foo và bar:

void setLog(const Foo & foo, const Bar & bar) {
    File* file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Nhưng sau đó ai chịu trách nhiệm xóa tập tin? Nếu không xóa tập tin, thì chúng ta có cả rò rỉ bộ nhớ và tài nguyên. Chúng tôi không biết liệu foo hoặc thanh sẽ kết thúc với tệp trước hay không, vì vậy chúng tôi không thể tự mình xóa tệp. Chẳng hạn, nếu foo xóa tệp trước khi thanh kết thúc với nó, thanh bây giờ có một con trỏ không hợp lệ.

Vì vậy, như bạn có thể đoán, chúng tôi có thể sử dụng con trỏ thông minh để giúp chúng tôi.

void setLog(const Foo & foo, const Bar & bar) {
    shared_ptr<File> file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Bây giờ, không ai cần phải lo lắng về việc xóa tệp - một khi cả foo và thanh đã kết thúc và không còn bất kỳ tham chiếu nào đến tệp (có thể do foo và thanh bị hủy), tệp sẽ tự động bị xóa.


7
Cần lưu ý rằng nhiều triển khai chuỗi được thực hiện theo thuật ngữ của một con trỏ đếm tham chiếu. Các ngữ nghĩa sao chép trên ghi này làm cho việc trả về một chuỗi theo giá trị thực sự không tốn kém.

7
Ngay cả đối với những cái không có, nhiều trình biên dịch thực hiện tối ưu hóa NRV sẽ đảm nhiệm phần trên. Nói chung, tôi thấy shared_ptr hiếm khi hữu ích - chỉ cần gắn bó với RAII và tránh sở hữu chung.
Nemanja Trifunovic

27
trả về một chuỗi không phải là một lý do tốt để sử dụng con trỏ thông minh thực sự. tối ưu hóa giá trị trả về có thể dễ dàng tối ưu hóa lợi nhuận và ngữ nghĩa di chuyển c ++ 1x sẽ loại bỏ hoàn toàn một bản sao (khi được sử dụng đúng cách). Thay vào đó hãy hiển thị một số ví dụ trong thế giới thực (ví dụ: khi chúng tôi chia sẻ cùng một tài nguyên) :)
Johannes Schaub - litb

1
Tôi nghĩ rằng kết luận của bạn sớm về lý do tại sao Java không thể làm điều này thiếu rõ ràng. Cách dễ nhất để mô tả giới hạn này trong Java hoặc C # là vì không có cách nào để phân bổ trên ngăn xếp. C # cho phép phân bổ ngăn xếp thông qua một từ khóa đặc biệt, tuy nhiên, bạn mất loại saftey.
ApplePieIsood

4
@Nemanja Trifunovic: Bởi RAII trong bối cảnh này, bạn có nghĩa là trả lại các bản sao / tạo các đối tượng trên ngăn xếp? Điều đó không hoạt động nếu bạn có các đối tượng trả về / chấp nhận các loại có thể được phân lớp. Sau đó, bạn phải sử dụng một con trỏ để tránh cắt đối tượng và tôi cho rằng một con trỏ thông minh thường tốt hơn con trỏ thô trong những trường hợp đó.
Frank Osterfeld

141

RAII Đây là một cái tên lạ cho một khái niệm đơn giản nhưng tuyệt vời. Tốt hơn là tên Phạm vi quản lý tài nguyên giới hạn (SBRM). Ý tưởng là thường thì bạn tình cờ phân bổ tài nguyên khi bắt đầu một khối và cần giải phóng nó ở lối ra của một khối. Thoát khỏi khối có thể xảy ra bằng cách kiểm soát dòng chảy bình thường, nhảy ra khỏi nó và thậm chí bằng một ngoại lệ. Để bao gồm tất cả các trường hợp này, mã trở nên phức tạp và dư thừa hơn.

Chỉ là một ví dụ làm điều đó mà không có SBRM:

void o_really() {
     resource * r = allocate_resource();
     try {
         // something, which could throw. ...
     } catch(...) {
         deallocate_resource(r);
         throw;
     }
     if(...) { return; } // oops, forgot to deallocate
     deallocate_resource(r);
}

Như bạn thấy có rất nhiều cách chúng ta có thể nhận được. Ý tưởng là chúng tôi gói gọn việc quản lý tài nguyên vào một lớp. Khởi tạo đối tượng của nó có được tài nguyên ("Thu nhận tài nguyên là khởi tạo"). Tại thời điểm chúng tôi thoát khỏi khối (phạm vi khối), tài nguyên được giải phóng lại.

struct resource_holder {
    resource_holder() {
        r = allocate_resource();
    }
    ~resource_holder() {
        deallocate_resource(r);
    }
    resource * r;
};

void o_really() {
     resource_holder r;
     // something, which could throw. ...
     if(...) { return; }
}

Điều đó thật tuyệt nếu bạn có các lớp của riêng chúng mà không chỉ dành cho mục đích phân bổ / phân bổ tài nguyên. Phân bổ sẽ chỉ là một mối quan tâm bổ sung để hoàn thành công việc của họ. Nhưng ngay khi bạn chỉ muốn phân bổ / phân bổ tài nguyên, những điều trên trở nên vô nghĩa. Bạn phải viết một lớp gói cho mọi loại tài nguyên bạn có được. Để giảm bớt điều đó, con trỏ thông minh cho phép bạn tự động hóa quá trình đó:

shared_ptr<Entry> create_entry(Parameters p) {
    shared_ptr<Entry> e(Entry::createEntry(p), &Entry::freeEntry);
    return e;
}

Thông thường, con trỏ thông minh là các trình bao bọc mỏng xung quanh mới / xóa chỉ xảy ra để gọi deletekhi tài nguyên mà chúng sở hữu vượt quá phạm vi. Một số con trỏ thông minh, như shared_ptr cho phép bạn nói với chúng một cái gọi là deleter, được sử dụng thay cho delete. Điều đó cho phép bạn, ví dụ, quản lý các điều khiển cửa sổ, tài nguyên biểu thức chính quy và các công cụ tùy ý khác, miễn là bạn nói với shared_ptr về trình phân tích đúng.

Có con trỏ thông minh khác nhau cho các mục đích khác nhau:

độc đáo

là một con trỏ thông minh sở hữu một đối tượng độc quyền. Nó không được tăng cường, nhưng nó có thể sẽ xuất hiện trong Tiêu chuẩn C ++ tiếp theo. Nó không thể sao chép nhưng hỗ trợ chuyển quyền sở hữu . Một số mã ví dụ (C ++ tiếp theo):

Mã số:

unique_ptr<plot_src> p(new plot_src); // now, p owns
unique_ptr<plot_src> u(move(p)); // now, u owns, p owns nothing.
unique_ptr<plot_src> v(u); // error, trying to copy u

vector<unique_ptr<plot_src>> pv; 
pv.emplace_back(new plot_src); 
pv.emplace_back(new plot_src);

Không giống như auto_ptr, unique_ptr có thể được đặt vào một thùng chứa, bởi vì các thùng chứa sẽ có thể giữ các loại không thể sao chép (nhưng có thể di chuyển), như các luồng và unique_ptr.

scoped_ptr

là một con trỏ thông minh boost không thể sao chép hay di chuyển được. Đó là thứ hoàn hảo được sử dụng khi bạn muốn đảm bảo con trỏ bị xóa khi đi ra khỏi phạm vi.

Mã số:

void do_something() {
    scoped_ptr<pipe> sp(new pipe);
    // do something here...
} // when going out of scope, sp will delete the pointer automatically. 

đã chia sẻ

dành cho sở hữu chung. Do đó, nó có thể sao chép và di chuyển được. Nhiều trường hợp con trỏ thông minh có thể sở hữu cùng một tài nguyên. Ngay khi con trỏ thông minh cuối cùng sở hữu tài nguyên đi ra khỏi phạm vi, tài nguyên sẽ được giải phóng. Một số ví dụ thực tế về một trong những dự án của tôi:

Mã số:

shared_ptr<plot_src> p(new plot_src(&fx));
plot1->add(p)->setColor("#00FF00");
plot2->add(p)->setColor("#FF0000");
// if p now goes out of scope, the src won't be freed, as both plot1 and 
// plot2 both still have references. 

Như bạn thấy, nguồn cốt truyện (hàm fx) được chia sẻ, nhưng mỗi nguồn có một mục riêng, trên đó chúng ta đặt màu. Có một lớp yếu_ptr được sử dụng khi mã cần tham chiếu đến tài nguyên được sở hữu bởi một con trỏ thông minh, nhưng không cần sở hữu tài nguyên. Thay vì chuyển một con trỏ thô, sau đó bạn nên tạo một yếu_ptr. Nó sẽ đưa ra một ngoại lệ khi nó thông báo bạn cố gắng truy cập tài nguyên bằng một đường dẫn truy cập yếu_ptr, mặc dù không có shared_ptr nữa sở hữu tài nguyên.


Theo như tôi biết thì các đối tượng không thể sao chép hoàn toàn không tốt để sử dụng trong các thùng chứa stl vì chúng dựa vào ngữ nghĩa giá trị - điều gì xảy ra nếu bạn muốn sắp xếp thùng chứa đó? sắp xếp sao chép các phần tử ...
fmuecke

Các thùng chứa C ++ 0x sẽ được thay đổi để nó tôn trọng các loại chỉ di chuyển unique_ptrsortcũng sẽ được thay đổi.
Julian Schaub - litb

Bạn có nhớ nơi bạn nghe thuật ngữ SBRM lần đầu tiên không? James đang cố gắng theo dõi nó.
GManNickG

Tôi nên bao gồm các tiêu đề hoặc thư viện để sử dụng chúng? bất kỳ bài đọc thêm về điều này?
atoMerz

Một lời khuyên ở đây: nếu có câu trả lời cho câu hỏi C ++ của @litb, thì đó là câu trả lời đúng (bất kể phiếu bầu hay câu trả lời được gắn cờ là "chính xác") ...
fnl 20/03/2015

32

Tiền đề và lý do là đơn giản, trong khái niệm.

RAII là mô hình thiết kế để đảm bảo rằng các biến xử lý tất cả các khởi tạo cần thiết trong các hàm tạo của chúng và tất cả các dọn dẹp cần thiết trong các hàm hủy của chúng. Điều này làm giảm tất cả khởi tạo và dọn dẹp xuống một bước duy nhất.

C ++ không yêu cầu RAII, nhưng ngày càng được chấp nhận rằng sử dụng các phương thức RAII sẽ tạo ra mã mạnh hơn.

Lý do RAII hữu ích trong C ++ là vì C ++ thực chất quản lý việc tạo và hủy các biến khi chúng nhập và rời khỏi phạm vi, cho dù thông qua dòng mã thông thường hoặc thông qua ngăn xếp được kích hoạt bởi một ngoại lệ. Đó là một phần mềm miễn phí trong C ++.

Bằng cách buộc tất cả các khởi tạo và dọn dẹp các cơ chế này, bạn được đảm bảo rằng C ++ cũng sẽ đảm nhiệm công việc này cho bạn.

Nói về RAII trong C ++ thường dẫn đến cuộc thảo luận về con trỏ thông minh, bởi vì con trỏ đặc biệt mong manh khi nói đến việc dọn dẹp. Khi quản lý bộ nhớ được phân bổ heap có được từ malloc hoặc mới, người lập trình thường có trách nhiệm giải phóng hoặc xóa bộ nhớ đó trước khi con trỏ bị hủy. Con trỏ thông minh sẽ sử dụng triết lý RAII để đảm bảo rằng các đối tượng được phân bổ heap bị phá hủy bất cứ khi nào biến con trỏ bị phá hủy.


Ngoài ra - con trỏ là ứng dụng phổ biến nhất của RAII - bạn có thể sẽ phân bổ con trỏ nhiều hơn hàng nghìn lần so với bất kỳ tài nguyên nào khác.
Nhật thực

8

Con trỏ thông minh là một biến thể của RAII. RAII có nghĩa là mua lại tài nguyên là khởi tạo. Con trỏ thông minh có được một tài nguyên (bộ nhớ) trước khi sử dụng và sau đó tự động ném nó đi trong một hàm hủy. Hai điều xảy ra:

  1. Chúng tôi phân bổ bộ nhớ trước khi chúng tôi sử dụng nó, luôn luôn, ngay cả khi chúng tôi không cảm thấy như vậy - thật khó để thực hiện một cách khác với một con trỏ thông minh. Nếu điều này không xảy ra, bạn sẽ cố gắng truy cập bộ nhớ NULL, dẫn đến sự cố (rất đau đớn).
  2. Chúng tôi giải phóng bộ nhớ ngay cả khi có lỗi. Không có bộ nhớ bị treo.

Ví dụ, một ví dụ khác là RAII socket mạng. Trong trường hợp này:

  1. Chúng tôi mở ổ cắm mạng trước khi sử dụng nó, luôn luôn, ngay cả khi chúng tôi không cảm thấy - thật khó để làm điều đó theo cách khác với RAII. Nếu bạn thử làm điều này mà không có RAII, bạn có thể mở ổ cắm trống cho, kết nối MSN. Sau đó, tin nhắn như "cho phép thực hiện tối nay" có thể không được chuyển, người dùng sẽ không bị sa thải và bạn có thể có nguy cơ bị sa thải.
  2. Chúng tôi đóng ổ cắm mạng ngay cả khi có lỗi. Không có ổ cắm nào bị treo vì điều này có thể ngăn thông báo phản hồi "chắc chắn bị bệnh ở phía dưới" khi đánh lại người gửi.

Bây giờ, như bạn có thể thấy, RAII là một công cụ rất hữu ích trong hầu hết các trường hợp vì nó giúp mọi người được đặt.

Nguồn C ++ của con trỏ thông minh có hàng triệu trên mạng bao gồm cả phản hồi ở trên tôi.


2

Boost có một số trong số này bao gồm cả những cái trong Boost.Inter Process cho bộ nhớ dùng chung. Nó đơn giản hóa rất nhiều việc quản lý bộ nhớ, đặc biệt là trong các tình huống gây đau đầu như khi bạn có 5 quy trình chia sẻ cùng một cấu trúc dữ liệu: khi mọi người thực hiện với một đoạn bộ nhớ, bạn muốn nó tự động được giải phóng & không phải ngồi đó cố gắng tìm ra ai sẽ chịu trách nhiệm gọi deletemột đoạn bộ nhớ, vì sợ rằng bạn sẽ bị rò rỉ bộ nhớ hoặc một con trỏ bị giải phóng nhầm hai lần và có thể làm hỏng cả đống.


0
void foo ()
{
   std :: thanh chuỗi;
   //
   // thêm mã ở đây
   //
}

Bất kể điều gì xảy ra, thanh sẽ bị xóa đúng khi phạm vi của hàm foo () bị bỏ lại phía sau.

Nội bộ std :: triển khai chuỗi thường sử dụng con trỏ đếm tham chiếu. Vì vậy, chuỗi nội bộ chỉ cần được sao chép khi một trong các bản sao của chuỗi thay đổi. Do đó, một tham chiếu đếm con trỏ thông minh làm cho nó chỉ có thể sao chép một cái gì đó khi cần thiết.

Ngoài ra, việc đếm tham chiếu nội bộ cho phép bộ nhớ sẽ bị xóa đúng khi bản sao của chuỗi bên trong không còn cần thiết.


1
void f () {Obj x; } Obj x bị xóa bằng cách tạo / hủy khung stack (giải nén) ... nó không liên quan đến việc đếm ref.
Hernán

Việc đếm tham chiếu là một tính năng của việc thực hiện bên trong chuỗi. RAII là khái niệm đằng sau việc xóa đối tượng khi đối tượng đi ra khỏi phạm vi. Câu hỏi là về RAII và cả con trỏ thông minh.

1
"Không có vấn đề gì xảy ra" - điều gì xảy ra nếu một ngoại lệ được ném trước khi hàm trả về?
titandecoy

Hàm nào được trả về? Nếu một ngoại lệ được ném trong foo, hơn thanh sẽ bị xóa. Hàm tạo mặc định của thanh ném ngoại lệ sẽ là một sự kiện bất thường.
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.