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ì?
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:
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.
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 delete
khi 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:
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.
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.
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.
unique_ptr
và sort
cũng sẽ được thay đổi.
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.
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:
Ví dụ, một ví dụ khác là RAII socket mạng. Trong trường hợp này:
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.
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 delete
mộ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.
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.