Tại sao Java / C # không thể triển khai RAII?


29

Câu hỏi: Tại sao Java / C # không thể triển khai RAII?

Làm rõ: Tôi biết rằng trình thu gom rác không mang tính quyết định. Vì vậy, với các tính năng ngôn ngữ hiện tại, phương thức Dispose () của đối tượng không thể được gọi tự động khi thoát khỏi phạm vi. Nhưng một tính năng xác định như vậy có thể được thêm vào?

Sự hiểu biết của tôi:

Tôi cảm thấy việc triển khai RAII phải đáp ứng hai yêu cầu:
1. Tuổi thọ của tài nguyên phải được ràng buộc trong một phạm vi.
2. Ngấm ngầm. Việc giải phóng tài nguyên phải xảy ra mà không có tuyên bố rõ ràng của lập trình viên. Tương tự như một bộ nhớ giải phóng bộ nhớ rác mà không có một tuyên bố rõ ràng. "Nhân chứng" chỉ cần xảy ra tại điểm sử dụng của lớp. Tất nhiên, người tạo thư viện lớp phải thực hiện rõ ràng một phương thức hàm hủy hoặc Dispose ().

Java / C # thỏa mãn điểm 1. Trong C #, một tài nguyên triển khai IDis có thể được liên kết với phạm vi "sử dụng":

void test()
{
    using(Resource r = new Resource())
    {
        r.foo();
    }//resource released on scope exit
}

Điều này không thỏa mãn điểm 2. Lập trình viên phải ràng buộc rõ ràng đối tượng vào một phạm vi "sử dụng" đặc biệt. Các lập trình viên có thể (và làm) quên kết nối tài nguyên một cách rõ ràng với một phạm vi, tạo ra sự rò rỉ.

Trong thực tế, các khối "bằng cách sử dụng" được trình biên dịch chuyển thành mã try-cuối-dispose (). Nó có cùng bản chất rõ ràng của mẫu thử-cuối cùng-dispose (). Không có một bản phát hành ngầm, móc vào một phạm vi là cú pháp đường.

void test()
{
    //Programmer forgot (or was not aware of the need) to explicitly
    //bind Resource to a scope.
    Resource r = new Resource(); 
    r.foo();
}//resource leaked!!!

Tôi nghĩ rằng đáng để tạo một tính năng ngôn ngữ trong Java / C # cho phép các đối tượng đặc biệt được nối với ngăn xếp thông qua một con trỏ thông minh. Tính năng này sẽ cho phép bạn gắn cờ một lớp là giới hạn phạm vi, để nó luôn được tạo bằng một móc vào ngăn xếp. Có thể có các tùy chọn cho các loại con trỏ thông minh khác nhau.

class Resource - ScopeBound
{
    /* class details */

    void Dispose()
    {
        //free resource
    }
}

void test()
{
    //class Resource was flagged as ScopeBound so the tie to the stack is implicit.
    Resource r = new Resource(); //r is a smart-pointer
    r.foo();
}//resource released on scope exit.

Tôi nghĩ rằng nhân chứng là "đáng giá". Giống như nhân chứng của việc thu gom rác là "đáng giá". Sử dụng các khối rõ ràng đang làm mới trên mắt, nhưng không cung cấp lợi thế về ngữ nghĩa so với thử-cuối-xử lý ().

Có phải là không thực tế khi triển khai một tính năng như vậy vào các ngôn ngữ Java / C # không? Nó có thể được giới thiệu mà không phá vỡ mã cũ?


3
Nó không thực tế, nó không thể . C # tiêu chuẩn không đảm bảo destructors / Disposes được bao giờ chạy, bất kể như thế nào họ đang kích hoạt. Thêm phá hủy ngầm ở cuối phạm vi sẽ không giúp điều đó.
Telastyn

20
@Telastyn Hả? Những gì tiêu chuẩn C # nói bây giờ không liên quan, vì chúng ta đang thảo luận về việc thay đổi chính tài liệu đó. Vấn đề duy nhất là liệu điều này có thực tế hay không, và điều thú vị duy nhất về việc thiếu bảo lãnh hiện tại là lý do cho sự thiếu bảo lãnh này. Lưu ý rằng để usingthực hiện Dispose được đảm bảo (tốt, giảm giá quá trình đột ngột chết mà không có ngoại lệ được ném, tại thời điểm đó, tất cả các hoạt động dọn dẹp có lẽ sẽ trở thành tranh luận).

4
trùng lặp Có phải các nhà phát triển của Java đã từ bỏ RAII một cách có ý thức? , mặc dù câu trả lời được chấp nhận là hoàn toàn không chính xác. Câu trả lời ngắn gọn là Java sử dụng ngữ nghĩa tham chiếu (heap) thay vì ngữ nghĩa giá trị (stack) , do đó, quyết toán xác định không hữu ích / khả thi. C # không có giá trị ngữ nghĩa ( struct), nhưng chúng thường được tránh ngoại trừ trong các trường hợp rất đặc biệt. Xem thêm .
BlueRaja - Daniel Pflughoeft

2
Nó tương tự, không trùng lặp chính xác.
Maniero

3
blog.msdn.com/b/oldnewthing/archive/2010/08/10/10048150.aspx là một trang có liên quan đến câu hỏi này.
Maniero

Câu trả lời:


17

Một phần mở rộng ngôn ngữ như vậy sẽ phức tạp và xâm lấn hơn đáng kể so với bạn nghĩ. Bạn không thể chỉ thêm

nếu thời gian tồn tại của một biến thuộc loại giới hạn ngăn xếp kết thúc, hãy gọi Disposeđối tượng mà nó đề cập đến

đến phần có liên quan của thông số ngôn ngữ và được thực hiện. Tôi sẽ bỏ qua vấn đề về các giá trị tạm thời ( new Resource().doSomething()) có thể được giải quyết bằng cách diễn đạt chung hơn một chút, đây không phải là vấn đề nghiêm trọng nhất. Ví dụ, mã này sẽ bị hỏng (và loại điều này có thể trở nên không thể thực hiện được nói chung):

File openSavegame(string id) {
    string path = ... id ...;
    File f = new File(path);
    // do something, perhaps logging
    return f;
} // f goes out of scope, caller receives a closed file

Bây giờ bạn cần các hàm tạo sao chép do người dùng định nghĩa (hoặc di chuyển các hàm tạo) và bắt đầu gọi chúng ở mọi nơi. Điều này không chỉ mang ý nghĩa hiệu suất, nó còn làm cho những thứ này có giá trị các loại một cách hiệu quả, trong khi hầu hết tất cả các đối tượng khác là các loại tham chiếu. Trong trường hợp của Java, đây là một sai lệch căn bản so với cách các đối tượng hoạt động. Trong C # ít hơn (đã có structs, nhưng không có hàm tạo sao chép do người dùng định nghĩa cho họ AFAIK), nhưng nó vẫn làm cho các đối tượng RAII này đặc biệt hơn. Ngoài ra, một phiên bản giới hạn của các loại tuyến tính (xem Rust) cũng có thể giải quyết vấn đề, với chi phí cấm răng cưa bao gồm cả việc truyền tham số (trừ khi bạn muốn giới thiệu thậm chí phức tạp hơn bằng cách áp dụng các tham chiếu mượn giống như Rust và trình kiểm tra mượn).

Nó có thể được thực hiện về mặt kỹ thuật, nhưng bạn kết thúc với một loại những thứ rất khác với mọi thứ khác trong ngôn ngữ. Đây hầu như luôn là một ý tưởng tồi, với hậu quả cho người thực hiện (nhiều trường hợp cạnh hơn, nhiều thời gian / chi phí hơn ở mỗi bộ phận) và người dùng (nhiều khái niệm để tìm hiểu, nhiều khả năng lỗi hơn). Nó không đáng để thêm tiện lợi.


Tại sao bạn cần sao chép / di chuyển constructor? Tệp vẫn là một loại tham chiếu. Trong tình huống đó, một con trỏ được sao chép vào người gọi và nó có trách nhiệm xử lý tài nguyên (trình biên dịch sẽ đặt một mẫu thử cuối cùng trong trình gọi thay thế)
Maniero

1
@bigown Nếu bạn đối xử với mọi tham chiếu Filetheo cách này, không có gì thay đổi và Disposekhông bao giờ được gọi. Nếu bạn luôn gọi Dispose, bạn không thể làm bất cứ điều gì với các đối tượng dùng một lần. Hoặc bạn đang đề xuất một số kế hoạch cho đôi khi xử lý và đôi khi không? Nếu vậy, xin vui lòng mô tả chi tiết và tôi sẽ cho bạn biết các tình huống trong đó nó thất bại.

Tôi không thấy những gì bạn nói bây giờ (tôi không nói bạn sai). Đối tượng có một tài nguyên, không phải là tài liệu tham khảo.
Maniero

Theo tôi hiểu, việc thay đổi ví dụ của bạn thành trả về, là trình biên dịch sẽ chèn thử ngay trước khi thu thập tài nguyên (dòng 3 tại ví dụ của bạn) và khối xử lý cuối cùng ngay trước khi kết thúc phạm vi (dòng 6). Không có vấn đề ở đây, đồng ý? Quay lại ví dụ của bạn. Trình biên dịch nhìn thấy một sự chuyển giao, nó không thể chèn thử cuối cùng ở đây nhưng người gọi sẽ nhận được một đối tượng Tệp (con trỏ tới) và giả sử người gọi không chuyển đối tượng này một lần nữa, trình biên dịch sẽ chèn mô hình thử cuối cùng ở đó. Nói cách khác, mọi đối tượng IDis Dùng không được chuyển cần áp dụng mẫu thử cuối cùng.
Maniero

1
@bigown Nói cách khác, đừng gọi Disposenếu tham chiếu thoát? Phân tích thoát là một vấn đề cũ và khó khăn, điều này sẽ không luôn hoạt động mà không thay đổi thêm về ngôn ngữ. Khi một tham chiếu được truyền cho một phương thức (ảo) khác ( something.EatFile(f);), nên f.Disposeđược gọi ở cuối phạm vi? Nếu có, bạn ngắt các cuộc gọi lưu trữ fđể sử dụng sau. Nếu không, bạn sẽ rò rỉ tài nguyên nếu người gọi không lưu trữ f. Cách duy nhất hơi đơn giản để loại bỏ điều này là một hệ thống loại tuyến tính, mà (như tôi đã thảo luận sau trong câu trả lời của tôi) thay vào đó giới thiệu nhiều biến chứng khác.

26

Khó khăn lớn nhất trong việc triển khai một cái gì đó như thế này cho Java hoặc C # sẽ là việc xác định cách thức chuyển giao tài nguyên hoạt động. Bạn sẽ cần một số cách để kéo dài tuổi thọ của tài nguyên ngoài phạm vi. Xem xét:

class IWrapAResource
{
    private readonly Resource resource;
    public IWrapAResource()
    {
        // Where Resource is scope bound
        Resource builder = new Resource(args, args, args);

        this.resource = builder;
    } // Uh oh, resource is destroyed
} // Crap, there's no scope for IWrapAResource we can bind to!

Điều tồi tệ hơn là điều này có thể không rõ ràng đối với người thực hiện IWrapAResource:

class IWrapSomething<T>
{
    private readonly T resource; // What happens if T is Resource?
    public IWrapSomething(T input)
    {
        this.resource = input;
    }
}

Một cái gì đó giống như usingtuyên bố của C # có thể gần giống như bạn sắp có ngữ nghĩa RAII mà không cần dùng đến tài nguyên đếm tham chiếu hoặc buộc ngữ nghĩa giá trị ở mọi nơi như C hoặc C ++. Vì Java và C # có chia sẻ ngầm các tài nguyên được quản lý bởi trình thu gom rác, nên lập trình viên tối thiểu cần có thể làm là chọn phạm vi mà tài nguyên bị ràng buộc, đó chính xác là những gì usingđã làm.


Giả sử bạn không có nhu cầu tham chiếu đến một biến sau khi nó vượt quá phạm vi (và thực sự không nên có nhu cầu như vậy), tôi khẳng định rằng bạn vẫn có thể tự tạo một đối tượng bằng cách viết một bộ hoàn thiện cho nó . Bộ hoàn thiện được gọi ngay trước khi đối tượng là rác được thu thập. Xem msdn.microsoft.com/en-us/l Library / 0s71x931.aspx
Robert Harvey

8
@Robert: Một chương trình được viết chính xác không thể giả sử người hoàn thành đã từng chạy. blog.msdn.com/b/oldnewthing/archive/2010/08/09/10047586.aspx
Billy ONeal

1
Hừm. Chà, đó có lẽ là lý do tại sao họ đưa ra usingtuyên bố.
Robert Harvey

2
Chính xác. Đây là một nguồn lớn các lỗi người mới trong C ++ và cũng sẽ có trong Java / C #. Java / C # không loại bỏ khả năng rò rỉ tham chiếu đến tài nguyên sắp bị phá hủy, nhưng bằng cách làm cho nó rõ ràng và tùy chọn, họ nhắc nhở lập trình viên và cho anh ta lựa chọn có ý thức về những việc cần làm.
Alexanderr Dubinsky

1
@svick Không phải là IWrapSomethingđể xử lý T. Bất cứ ai được tạo ra đều Tphải lo lắng về điều đó, cho dù sử dụng using, là IDisposablechính nó, hoặc có một số sơ đồ vòng đời tài nguyên đặc biệt.
Alexanderr Dubinsky

13

Lý do tại sao RAII không thể hoạt động với ngôn ngữ như C #, nhưng nó hoạt động trong C ++, là vì trong C ++, bạn có thể quyết định liệu một đối tượng có thực sự tạm thời hay không (bằng cách phân bổ nó trên ngăn xếp) hoặc liệu nó có tồn tại lâu không (bởi phân bổ nó trên heap bằng cách sử dụng newvà sử dụng con trỏ).

Vì vậy, trong C ++, bạn có thể làm một cái gì đó như thế này:

void f()
{
    Foo f1;
    Foo* f2 = new Foo();
    Foo::someStaticField = f2;

    // f1 is destroyed here, the object pointed to by f2 isn't
}

Trong C #, bạn không thể phân biệt giữa hai trường hợp, vì vậy trình biên dịch sẽ không biết có nên hoàn thiện đối tượng hay không.

Những gì bạn có thể làm là giới thiệu một số loại biến cục bộ đặc biệt, mà bạn không thể đưa vào các trường, v.v. * và nó sẽ tự động được xử lý khi nó vượt quá phạm vi. Đó chính xác là những gì C ++ / CLI làm. Trong C ++ / CLI, bạn viết mã như thế này:

void f()
{
    Foo f1;
    Foo^ f2 = gcnew Foo();
    Foo::someStaticField = f2;

    // f1 is disposed here, the object pointed to by f2 isn't
}

Điều này biên dịch về cơ bản cùng IL như C # sau:

void f()
{
    using (Foo f1 = new Foo())
    {
        Foo f2 = new Foo();
        Foo.someStaticField = f2;
    }
    // f1 is disposed here, the object pointed to by f2 isn't
}

Để kết luận, nếu tôi đoán tại sao các nhà thiết kế của C # không thêm RAII, thì họ nghĩ rằng việc có hai loại biến cục bộ khác nhau không đáng, chủ yếu là vì trong ngôn ngữ với GC, quyết toán xác định không hữu ích thường xuyên

* Không phải không có tương đương với &toán tử, trong C ++ / CLI là %. Mặc dù làm như vậy là không an toàn, theo nghĩa là sau khi phương thức kết thúc, trường sẽ tham chiếu đến một đối tượng được xử lý.


1
C # có thể làm RAII một cách tầm thường nếu nó cho phép các hàm hủy đối với structcác loại như D.
Jan Hudec

6

Nếu những gì làm phiền bạn với usingcác khối là nhân chứng của họ, có lẽ chúng ta có thể bước một bước nhỏ bé về phía ít nhân chứng hơn là thay đổi chính thông số kỹ thuật C #. Hãy xem xét mã này:

public void ReadFile ()
{
  string filename = "myFile.dat";
  local Stream file = File.Open(filename);
  file.Read(blah blah blah);
}

Xem localtừ khóa tôi đã thêm? Tất cả những gì nó làm là thêm một chút đường cú pháp, giống như using, bảo trình biên dịch gọi Disposemột finallykhối ở cuối phạm vi của biến. Đó là tất cả. Nó hoàn toàn tương đương với:

public void ReadFile ()
{
  string filename = "myFile.dat";
  using (Stream file = File.Open(filename))
  {
      file.Read(blah blah blah);
  }
}

nhưng với một phạm vi ngầm, chứ không phải là một phạm vi rõ ràng. Nó đơn giản hơn các đề xuất khác vì tôi không phải có lớp được định nghĩa là giới hạn phạm vi. Chỉ cần sạch hơn, đường cú pháp ngầm hơn.

Có thể có vấn đề ở đây với phạm vi khó giải quyết, mặc dù tôi không thể nhìn thấy nó ngay bây giờ và tôi đánh giá cao bất cứ ai có thể tìm thấy nó.


1
@ mike30 nhưng việc chuyển nó sang định nghĩa kiểu dẫn bạn chính xác đến các vấn đề khác được liệt kê - điều gì xảy ra nếu bạn chuyển con trỏ đến một phương thức khác hoặc trả nó từ hàm? Bằng cách này, phạm vi được khai báo trong phạm vi, không phải nơi khác. Một loại có thể là Dùng một lần, nhưng không phải là gọi nó là Vứt bỏ.
Avner Shahar-Kashtan

3
@ m3030: Meh. Tất cả cú pháp này là loại bỏ các dấu ngoặc nhọn và, bằng cách mở rộng, kiểm soát phạm vi mà chúng cung cấp.
Robert Harvey

1
@RobertHarvey Chính xác. Nó hy sinh một số tính linh hoạt cho mã sạch hơn, ít lồng nhau hơn. Nếu chúng tôi sử dụng đề xuất của @ delnan và sử dụng lại usingtừ khóa, chúng tôi có thể giữ hành vi hiện có và sử dụng nó cho các trường hợp chúng tôi không cần phạm vi cụ thể. Có usingmặc định niềng răng trong phạm vi hiện tại.
Avner Shahar-Kashtan

1
Tôi không gặp vấn đề gì với các bài tập bán thực tế trong thiết kế ngôn ngữ.
Avner Shahar-Kashtan

1
@RobertHarvey. Bạn dường như có một thành kiến ​​chống lại bất cứ điều gì hiện không được thực hiện trong C #. Chúng tôi sẽ không có thuốc generic, linq, khối sử dụng, loại ipmlicit, v.v. nếu chúng tôi hài lòng với C # 1.0. Cú pháp này không giải quyết được vấn đề của nhân chứng, nhưng nó là một loại đường tốt để liên kết với phạm vi hiện tại.
mike30

1

Để biết ví dụ về cách RAII hoạt động với ngôn ngữ được thu gom rác, hãy kiểm tra withtừ khóa trong Python . Thay vì dựa vào các đối tượng bị phá hủy xác định, nó cho phép bạn liên kết __enter__()__exit__()phương thức với một phạm vi từ vựng nhất định. Một ví dụ phổ biến là:

with open('output.txt', 'w') as f:
    f.write('Hi there!')

Như với kiểu RAII của C ++, tệp sẽ bị đóng khi thoát khỏi khối đó, bất kể đó là lối thoát 'bình thường', a break, ngay lập tức returnhay ngoại lệ.

Lưu ý rằng open()cuộc gọi là chức năng mở tệp thông thường. Để thực hiện công việc này, đối tượng tệp được trả về bao gồm hai phương thức:

def __enter__(self):
  return self
def __exit__(self):
  self.close()

Đây là một thành ngữ phổ biến trong Python: các đối tượng được liên kết với một tài nguyên thường bao gồm hai phương thức này.

Lưu ý rằng đối tượng tập tin vẫn có thể vẫn được phân bổ sau __exit__()cuộc gọi, điều quan trọng là nó đã bị đóng.


7
withtrong Python gần như chính xác như usingtrong C #, và như vậy không phải là RAII theo như câu hỏi này.

1
"Với" của Python là quản lý tài nguyên giới hạn phạm vi nhưng nó thiếu nhân chứng của một con trỏ thông minh. Hành động khai báo một con trỏ là thông minh có thể được coi là "rõ ràng", nhưng nếu trình biên dịch thực thi sự thông minh như là một phần của loại đối tượng, nó sẽ nghiêng về phía "ẩn".
mike30

AFAICT, quan điểm của RAII đang thiết lập phạm vi nghiêm ngặt đối với tài nguyên. nếu bạn chỉ quan tâm đến việc thực hiện bằng cách sắp xếp các đối tượng, thì không, các ngôn ngữ thu gom rác không thể làm điều đó. nếu bạn quan tâm đến việc liên tục phát hành tài nguyên, thì đây là một cách để làm điều đó (một cách khác là deferbằng ngôn ngữ Go).
Javier

1
Trên thực tế, tôi nghĩ thật công bằng khi nói rằng Java và C # rất ủng hộ các công trình rõ ràng. Nếu không, tại sao phải bận tâm với tất cả các nghi lễ vốn có trong việc sử dụng Giao diện và kế thừa?
Robert Harvey

1
@delnan, Go không có giao diện 'ngầm'.
Javier
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.