Dlaczego Java / C # nie może implementować RAII?

29

Pytanie: Dlaczego Java / C # nie może implementować RAII?

Wyjaśnienie: Wiem, że śmieciarz nie jest deterministyczny. Dlatego przy obecnych funkcjach językowych nie jest możliwe automatyczne wywoływanie metody Dispose () obiektu przy wyjściu z zakresu. Ale czy można dodać taką cechę deterministyczną?

Moje zrozumienie:


Wydaje mi się, że implementacja RAII musi spełniać dwa wymagania: 1. Żywotność zasobu musi być związana z zakresem.
2. Implikowany. Zwolnienie zasobu musi nastąpić bez wyraźnego oświadczenia programisty. Analogicznie do śmieciarza zwalniającego pamięć bez wyraźnego oświadczenia. „Ujawnienie” musi wystąpić tylko w momencie użycia klasy. Twórca biblioteki klas musi oczywiście jawnie zaimplementować metodę destructor lub Dispose ().

Java / C # spełnia punkt 1. W C # zasób implementujący IDisposable może być powiązany z zakresem „using”:

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

Nie spełnia to punktu 2. Programista musi jawnie powiązać obiekt ze specjalnym zakresem „używającym”. Programiści mogą (i robią) zapomnieć o jawnym powiązaniu zasobu z zakresem, tworząc wyciek.

W rzeczywistości bloki „using” są konwertowane przez kompilator na kod try-ultimate-dispose (). Ma tę samą wyraźną naturę wzorca try-last-dispose (). Bez domniemanego zwolnienia hakiem do zakresu jest cukier składniowy.

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!!!

Myślę, że warto utworzyć funkcję języka w Javie / C #, która pozwala na specjalne obiekty, które są zaczepiane do stosu za pomocą inteligentnego wskaźnika. Ta funkcja umożliwia oznaczenie klasy jako związanej z zakresem, dzięki czemu zawsze jest tworzona z zaczepem do stosu. Mogą istnieć opcje dla różnych rodzajów inteligentnych wskaźników.

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.

Myślę, że domniemanie jest „tego warte”. Podobnie jak niejawność zbierania śmieci jest „tego warta”. Jawne używanie bloków odświeża oczy, ale nie zapewnia przewagi semantycznej nad try-last-dispose ().

Czy zaimplementowanie takiej funkcji w językach Java / C # jest niepraktyczne? Czy można to wprowadzić bez zerwania starego kodu?

mike30
źródło
3
To nie jest niepraktyczne, to niemożliwe . C # norma nie gwarantuje destruktorów / Disposes są zawsze uruchamiane, niezależnie od tego, jak są one wyzwalane. Dodanie ukrytego zniszczenia na końcu zakresu nie pomoże.
Telastyn
20
@Telastyn Huh? To, co mówi teraz standard C #, nie ma znaczenia, ponieważ dyskutujemy o zmianie tego samego dokumentu. Jedyną kwestią jest to, czy jest to praktyczne, a jedyną interesującą kwestią dotyczącą obecnego braku gwarancji są przyczyny tego braku gwarancji. Zauważ, że dla usingwykonania Dispose jest gwarantowane (cóż, pomijanie procesu nagle umiera bez wyjątku, w którym to momencie przypuszczalnie wszystkie porządki stają się dyskusyjne).
4
duplikat Czy programiści Java świadomie porzucili RAII? , chociaż zaakceptowana odpowiedź jest całkowicie niepoprawna. Krótka odpowiedź jest taka, że ​​Java używa semantyki odniesienia (stosu) zamiast semantyki wartości (stosu) , więc deterministyczna finalizacja nie jest zbyt użyteczna / możliwa. C # nie mają wartości dodanej, semantyki ( struct), ale są one zazwyczaj unika z wyjątkiem bardzo szczególnych przypadkach. Zobacz także .
BlueRaja - Danny Pflughoeft
2
Jest podobny, a nie dokładny duplikat.
Maniero
3
blogs.msdn.com/b/oldnewthing/archive/2010/08/10/10048150.aspx jest odpowiednią stroną tego pytania.
Maniero

Odpowiedzi:

17

Takie rozszerzenie języka byłoby znacznie bardziej skomplikowane i inwazyjne, niż się wydaje. Nie możesz po prostu dodać

jeśli kończy się czas życia zmiennej typu stosu, wywołaj Disposeobiekt, do którego się ona odnosi

do odpowiedniej sekcji specyfikacji języka i gotowe. Zignoruję problem wartości tymczasowych ( new Resource().doSomething()), który można rozwiązać nieco bardziej ogólnym sformułowaniem, nie jest to najpoważniejszy problem. Na przykład ten kod zostałby zepsuty (i tego rodzaju rzeczy prawdopodobnie stają się ogólnie niemożliwe):

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

Teraz potrzebujesz zdefiniowanych przez użytkownika konstruktorów kopiowania (lub przenieś konstruktorów) i zacznij je wywoływać wszędzie. Nie tylko ma to wpływ na wydajność, ale także sprawia, że ​​te rzeczy skutecznie wyceniają typy, podczas gdy prawie wszystkie inne obiekty są typami referencyjnymi. W przypadku Javy jest to radykalne odchylenie od sposobu działania obiektów. W C # mniej tak (już ma structs, ale nie ma dla nich AFAIK zdefiniowanych przez użytkownika konstruktorów kopiowania), ale nadal czyni te obiekty RAII bardziej specjalnymi. Alternatywnie, ograniczona wersja typów liniowych (por. Rust) może również rozwiązać problem, kosztem zakazu aliasingu, w tym przekazywania parametrów (chyba że chcesz wprowadzić jeszcze większą złożoność poprzez przyjęcie pożyczonych odniesień podobnych do rdzy i sprawdzania pożyczek).

Można to zrobić technicznie, ale kończy się na kategorii rzeczy, które bardzo różnią się od wszystkiego innego w języku. Jest to prawie zawsze zły pomysł, który ma konsekwencje dla implementatorów (więcej przypadków, więcej czasu / kosztów w każdym dziale) i użytkowników (więcej pomysłów do nauczenia się, więcej możliwości błędów). To nie jest warte dodatkowej wygody.


źródło
Dlaczego potrzebujesz konstruktora kopiuj / przenieś? Plik zachowuje typ referencyjny. W tej sytuacji f, która jest wskaźnikiem jest kopiowany do rozmówcy i jest odpowiedzialny do dysponowania zasób (kompilator niejawnie stawiałaby wzór try-końcu-Usuwać w rozmówcy zamiast)
Maniero
1
@bigown Jeśli traktujesz każde odniesienie w Fileten sposób, nic się nie zmienia i Disposenigdy nie jest wywoływane. Jeśli zawsze dzwonisz Dispose, nie możesz nic zrobić z przedmiotami jednorazowymi. A może proponujesz jakiś plan, jak go pozbyć, a czasem nie? Jeśli tak, opisz to szczegółowo, a powiem ci, w jakich sytuacjach się nie udaje.
Nie rozumiem tego, co powiedziałeś teraz (nie mówię, że się mylisz). Obiekt ma zasób, a nie odwołanie.
Maniero
Rozumiem, że zmieniając przykład na zwrot, kompilator wstawiłby próbę tuż przed akwizycją zasobów (linia 3 w twoim przykładzie) i blokiem końcowym tuż przed końcem zakresu (linia 6). Nie ma problemu, zgadzasz się? Wróć do twojego przykładu. Kompilator widzi przeniesienie, nie mógł tutaj wstawić try-last, ale program wywołujący otrzyma obiekt File (wskaźnik do) i zakładając, że program wywołujący nie przenosi tego obiektu ponownie, kompilator wstawi tam wzór try-last. Innymi słowy, każdy nieprzenoszony obiekt IDisposable musi zastosować wzorzec try-last.
Maniero
1
@bigown Innymi słowy, nie dzwoń, Disposejeśli referencja ucieka? Analiza ucieczki jest starym i trudnym problemem, nie zawsze będzie działać bez dalszych zmian w języku. Kiedy odniesienie jest przekazywane do innej (wirtualnej) metody ( something.EatFile(f);), należy f.Disposewywołać na końcu zakresu? Jeśli tak, przerywasz dzwoniące, które przechowują fdo późniejszego wykorzystania. Jeśli nie, wycieksz z zasobu, jeśli dzwoniący nie zapisze f. Jedynym dość prostym sposobem na usunięcie tego jest system liniowy, który (jak już omówiłem w dalszej części mojej odpowiedzi) wprowadza wiele innych komplikacji.
26

Największą trudnością we wdrożeniu czegoś takiego w Javie lub C # byłoby zdefiniowanie, jak działa transfer zasobów. Potrzebujesz sposobu, aby przedłużyć żywotność zasobu poza zakres. Rozważać:

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!

Co gorsza, może to nie być oczywiste dla osoby wdrażającej IWrapAResource:

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

Coś w rodzaju usingoświadczenia C # jest prawdopodobnie tak blisko, jak masz zamiar mieć semantykę RAII bez uciekania się do zasobów liczących odniesienia lub wymuszania semantyki wartości wszędzie, takich jak C lub C ++. Ponieważ Java i C # mają niejawne współużytkowanie zasobów zarządzanych przez moduł wyrzucający elementy bezużyteczne, programista musi być w stanie zrobić to wybrać zakres, do którego zasób jest związany, co jest dokładnie tym, co usingjuż robi.

Billy ONeal
źródło
Zakładając, że nie musisz odwoływać się do zmiennej po jej wykryciu poza zakres (i naprawdę nie powinna być takiej potrzeby), twierdzę, że nadal możesz sprawić, że obiekt sam się wyrzuci, pisząc dla niego finalizator . Finalizator jest wywoływany tuż przed zbieraniem śmieci. Zobacz msdn.microsoft.com/en-us/library/0s71x931.aspx
Robert Harvey
8
@Robert: Prawidłowo napisany program nie może zakładać, że finalizatory kiedykolwiek działają. blogs.msdn.com/b/oldnewthing/archive/2010/08/09/10047586.aspx
Billy ONeal
1
Hm Prawdopodobnie dlatego wymyślili to usingoświadczenie.
Robert Harvey
2
Dokładnie. Jest to ogromne źródło błędów dla początkujących w C ++, a także w Javie / C #. Java / C # nie eliminuje możliwości wycieku odniesienia do zasobu, który ma zostać zniszczony, ale czyniąc go zarówno jawnym, jak i opcjonalnym, przypomina programistom i daje mu świadomy wybór co zrobić.
Aleksandr Dubinsky
1
@svick Nie należy się IWrapSomethinggo pozbywać T. Ktokolwiek stworzył, Tmusi się o to martwić, niezależnie od tego, czy korzysta using, jest IDisposablesobą, czy ma jakiś schemat cyklu życia zasobów ad-hoc.
Aleksandr Dubinsky
13

Powodem, dla którego RAII nie może działać w języku takim jak C #, ale działa w C ++, jest to, że w C ++ możesz zdecydować, czy obiekt jest naprawdę tymczasowy (przydzielając go na stosie), czy też jest długowieczny (przez przydzielanie go na stercie za newpomocą wskaźników).

Tak więc w C ++ możesz zrobić coś takiego:

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

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

W języku C # nie można rozróżnić dwóch przypadków, więc kompilator nie miałby pojęcia, czy sfinalizować obiekt, czy nie.

Możesz wprowadzić specjalny rodzaj zmiennej lokalnej, którego nie możesz wstawiać do pól itp. *, A który automatycznie zostanie usunięty, gdy wykracza poza zakres. To właśnie robi C ++ / CLI. W C ++ / CLI piszesz taki kod:

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

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

To kompiluje się w zasadzie do tej samej IL, co następujący C #:

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
}

Podsumowując, jeśli zgadnę, dlaczego projektanci C # nie dodali RAII, to dlatego, że uważali, że posiadanie dwóch różnych typów zmiennych lokalnych nie jest tego warte, głównie dlatego, że w języku z GC finalizacja deterministyczna nie jest przydatna, często.

* Nie bez odpowiednika &operatora, którym jest C ++ / CLI %. Chociaż jest to „niebezpieczne” w tym sensie, że po zakończeniu metody pole będzie odnosić się do rozmieszczonego obiektu.

svick
źródło
1
C # mógłby w trywialny sposób wykonywać RAII, gdyby pozwalał na niszczyciele dla structtypów takich jak D.
Jan Hudec
6

Jeśli usingblokowanie przeszkadza ci w ich jawności, być może możemy zrobić mały krok w stronę mniejszej jawności, zamiast zmieniać samą specyfikację C #. Rozważ ten kod:

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

Widzisz localdodane słowo kluczowe? Wszystko robi to dodać nieco więcej cukru składniowej, podobnie jak using, informując kompilator zadzwonić Disposew finallybloku na końcu zakresu zmiennej. To wszystko. Jest to całkowicie równoważne z:

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

ale z zakresem domniemanym, a nie jawnym. Jest to prostsze niż inne sugestie, ponieważ nie muszę definiować klasy jako ograniczonej zasięgiem. Po prostu czystszy, bardziej domyślny cukier składniowy.

Mogą występować problemy z trudnymi do rozwiązania zakresami, chociaż nie widzę ich teraz i byłbym wdzięczny każdemu, kto może to znaleźć.

Avner Shahar-Kashtan
źródło
1
@ mike30, ale przeniesienie go do definicji typu prowadzi dokładnie do problemów wymienionych na liście - co się stanie, jeśli przekażesz wskaźnik innej metodzie lub zwrócisz go z funkcji? W ten sposób zakres jest zadeklarowany w zakresie, a nie gdzie indziej. Typ może być jednorazowy, ale nie należy do niego wywoływać Dispose.
Avner Shahar-Kashtan
3
@ mike30: Meh. Cała ta składnia polega na usuwaniu nawiasów klamrowych, a przez to zapewnianej przez nie kontroli zakresu.
Robert Harvey
1
@RobertHarvey Dokładnie. Poświęca pewną elastyczność dla czystszego, mniej zagnieżdżonego kodu. Jeśli weźmiemy sugestię @ delnan i ponownie usingużyjemy słowa kluczowego, możemy zachować istniejące zachowanie i użyć go również w przypadkach, gdy nie potrzebujemy określonego zakresu. Mieć usingdomyślny zakres bez nawiasów .
Avner Shahar-Kashtan
1
Nie mam problemu z półpraktycznymi ćwiczeniami z projektowania języka.
Avner Shahar-Kashtan
1
@RobertHarvey. Wydaje się, że masz uprzedzenia do wszystkiego, co nie jest obecnie zaimplementowane w języku C #. Gdybyśmy byli zadowoleni z C # 1.0, nie mielibyśmy generycznych, linq, using-blocków, typów ipmlicit itp. Ta składnia nie rozwiązuje problemu niejawności, ale dobrym cukrem jest powiązanie z bieżącym zakresem.
mike30
1

Na przykład, jak RAII działa w języku śmieci, sprawdź withsłowo kluczowe w Pythonie . Zamiast polegać na deterministycznie zniszczonych obiektach, pozwala ci powiązać __enter__()i __exit__()metody z danym zakresem leksykalnym. Typowym przykładem jest:

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

Podobnie jak w stylu RAII w C ++, plik byłby zamykany przy wychodzeniu z tego bloku, bez względu na to, czy jest to „normalne” wyjście, a break, natychmiast returnczy wyjątek.

Pamiętaj, że open()wywołanie jest zwykłą funkcją otwierania plików. aby to zadziałało, zwrócony obiekt pliku zawiera dwie metody:

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

Jest to powszechny idiom w Pythonie: obiekty powiązane z zasobem zazwyczaj zawierają te dwie metody.

Zauważ, że obiekt pliku może nadal pozostać przydzielony po __exit__()wywołaniu, ważne jest to, że jest zamknięty.

Javier
źródło
7
withw Pythonie jest prawie dokładnie tak jak usingw C # i jako takie nie jest RAII, jeśli chodzi o to pytanie.
1
Python „with” to zarządzanie zasobami ograniczone do zakresu, ale brakuje im niejawności inteligentnego wskaźnika. Zadeklarowanie wskaźnika jako inteligentnego można uznać za „jawne”, ale jeśli kompilator wymusi inteligentność jako część typu obiektów, skłoni się do „niejawnego”.
mike30
AFAICT, celem RAII jest ścisłe określenie zakresu zasobów. jeśli interesuje Cię tylko dezalokacja obiektów, to nie, języki zrzucane przez śmieci nie mogą tego zrobić. jeśli jesteś zainteresowany konsekwentnym uwalnianiem zasobów, to jest to sposób, aby to zrobić (inny jest deferw języku Go).
Javier
1
Właściwie myślę, że można śmiało powiedzieć, że Java i C # zdecydowanie sprzyjają jawnym konstrukcjom. W przeciwnym razie, po co zawracać sobie głowę ceremonią związaną z używaniem interfejsów i dziedziczeniem?
Robert Harvey
1
@delnan, Go ma „niejawne” interfejsy.
Javier