Czy implementowanie interfejsów przez struktury jest bezpieczne?

96

Wydaje mi się, że czytałem coś o tym, jak złe jest dla struktur implementowanie interfejsów w CLR za pośrednictwem C #, ale nie mogę znaleźć nic na ten temat. To jest złe? Czy istnieją niezamierzone konsekwencje takiego postępowania?

public interface Foo { Bar GetBar(); }
public struct Fubar : Foo { public Bar GetBar() { return new Bar(); } }
nawfal
źródło

Odpowiedzi:

46

W tym pytaniu dzieje się kilka rzeczy ...

Struktura może zaimplementować interfejs, ale istnieją obawy związane z rzutowaniem, zmiennością i wydajnością. Zobacz ten post, aby uzyskać więcej informacji: https://docs.microsoft.com/en-us/archive/blogs/abhinaba/c-structs-and-interface

Ogólnie rzecz biorąc, struktury powinny być używane dla obiektów, które mają semantykę typu wartości. Implementując interfejs w strukturze, możesz napotkać problemy związane z boksowaniem, ponieważ struktura jest rzutowana tam iz powrotem między strukturą a interfejsem. W wyniku boxingu operacje zmieniające stan wewnętrzny konstrukcji mogą nie zachowywać się prawidłowo.

Scott Dorman
źródło
3
„W wyniku pakowania operacje, które zmieniają stan wewnętrzny konstrukcji, mogą nie zachowywać się prawidłowo”. Podaj przykład i uzyskaj odpowiedź.
2
@Will: Nie jestem pewien, o czym mówisz w swoim komentarzu. W poście na blogu, do którego się odwołałem, znajduje się przykład pokazujący, gdzie wywołanie metody interfejsu w strukturze nie zmienia w rzeczywistości wartości wewnętrznej.
Scott Dorman,
12
@ScottDorman: W niektórych przypadkach posiadanie struktur implementujących interfejsy może pomóc uniknąć boksu. Najważniejsze przykłady to IComparable<T>i IEquatable<T>. Przechowywanie struktury Foow zmiennej typu IComparable<Foo>wymagałoby zapakowania, ale jeśli typ ogólny Tjest ograniczony do IComparable<T>jednego, można go porównać z innym Tbez konieczności umieszczania żadnego z nich w pudełku i bez konieczności posiadania wiedzy na temat Tinnych rzeczy niż to, że implementuje ograniczenie. Takie korzystne zachowanie jest możliwe tylko dzięki zdolności struktur do implementowania interfejsów. Powiedziawszy to ...
supercat
3
... byłoby miło, gdyby istniał sposób zadeklarowania, że ​​określony interfejs powinien być uważany za mający zastosowanie tylko do struktur bez pudełek, ponieważ istnieją konteksty, w których nie byłoby możliwe, aby obiekt klasy lub struktura pudełkowa miały pożądane zachowania.
supercat
2
„Struktury powinny być używane dla obiektów, które mają semantykę typu wartości. ... operacje, które zmieniają stan wewnętrzny struktury, mogą nie działać poprawnie.” Czy prawdziwym problemem nie jest fakt, że semantyka typu wartości i zmienność nie są dobrze połączone?
jpmc26
186

Ponieważ nikt inny wyraźnie nie udzielił tej odpowiedzi, dodam, co następuje:

Implementacja interfejsu w strukturze nie ma żadnych negatywnych konsekwencji.

Każda zmienna typu interfejsu używana do przechowywania struktury spowoduje, że zostanie użyta wartość pudełkowa tej struktury. Jeśli struktura jest niezmienna (dobrze), w najgorszym przypadku jest to problem z wydajnością, chyba że:

  • używanie powstałego obiektu do blokowania (w każdym razie niezmiernie zły pomysł)
  • używając semantyki równości odwołań i oczekując, że będzie działać dla dwóch wartości opakowanych z tej samej struktury.

Oba byłyby mało prawdopodobne, zamiast tego prawdopodobnie wykonasz jedną z następujących czynności:

Generics

Być może wiele rozsądnych powodów, dla których struktury implementujące interfejsy są takie, że mogą być używane w ogólnym kontekście z ograniczeniami . Użyta w ten sposób zmienna taka jak ta:

class Foo<T> : IEquatable<Foo<T>> where T : IEquatable<T>
{
    private readonly T a;

    public bool Equals(Foo<T> other)
    {
         return this.a.Equals(other.a);
    }
}
  1. Włącz użycie struktury jako parametru typu
    • o ile żadne inne ograniczenie nie jest takie jak new()lub nie classjest używane.
  2. Pozwól na unikanie boksowania na konstrukcjach używanych w ten sposób.

Wtedy this.a NIE jest odwołaniem do interfejsu, więc nie powoduje umieszczenia w nim pudełka. Ponadto, gdy kompilator C # kompiluje klasy generyczne i musi wstawić wywołania metod instancji zdefiniowanych w instancjach parametru Type T, może użyć ograniczonego opcode:

Jeśli thisType jest typem wartości i thisType implementuje metodę, to ptr jest przekazywany niezmodyfikowany jako wskaźnik „this” do instrukcji metody wywołania, w celu implementacji metody przez thisType.

Pozwala to uniknąć boksowania, a ponieważ typ wartości jest implementowany, interfejs musi implementować metodę, dlatego nie wystąpią żadne opakowania. W powyższym przykładzie Equals()wywołanie jest wykonywane bez ramki. A 1 .

API o niskim współczynniku tarcia

Większość struktur powinna mieć semantykę prymitywną, w której identyczne wartości bitowe są uważane za równe 2 . Środowisko wykonawcze zapewni takie zachowanie w sposób niejawny, Equals()ale może to być powolne. Również ta niejawna równość nie jest ujawniana jako implementacja, IEquatable<T>a zatem zapobiega łatwemu używaniu struktur jako kluczy dla słowników, chyba że jawnie zaimplementują je samodzielnie. W związku z tym często zdarza się, że wiele typów struktur publicznych deklaruje, że implementują IEquatable<T>(gdzie Tsą one siebie), aby ułatwić i poprawić wydajność, a także zachować spójność z zachowaniem wielu istniejących typów wartości w CLR BCL.

Wszystkie prymitywy w implementacji BCL to minimum:

  • IComparable
  • IConvertible
  • IComparable<T>
  • IEquatable<T>(A więc IEquatable)

Wiele z nich również implementuje IFormattable, a ponadto wiele typów wartości zdefiniowanych w systemie, takich jak DateTime, TimeSpan i Guid, implementuje również wiele lub wszystkie z nich. Jeśli implementujesz podobnie „szeroko użyteczny” typ, jak struktura liczb zespolonych lub niektóre wartości tekstowe o stałej szerokości, to zaimplementowanie wielu z tych wspólnych interfejsów (poprawnie) sprawi, że twoja struktura będzie bardziej użyteczna i użyteczna.

Wyłączenia

Oczywiście, jeśli interfejs silnie implikuje zmienność (taką jak ICollection), to zaimplementowanie go jest złym pomysłem, ponieważ oznaczałoby to, że albo dokonałeś mutacji struktury (co prowadzi do tego rodzaju błędów opisanych już, gdzie modyfikacje występują na wartości pudełkowej, a nie oryginalnej ) lub dezorientujesz użytkowników, ignorując konsekwencje metod takich jak Add()lub zgłaszanie wyjątków.

Wiele interfejsów NIE implikuje zmienności (na przykład IFormattable) i służy jako idiomatyczny sposób na wyeksponowanie pewnych funkcji w spójny sposób. Często użytkownik struktury nie będzie przejmował się jakimkolwiek narzutem związanym z boksowaniem za takie zachowanie.

Podsumowanie

Jeśli jest to zrobione rozsądnie, na niezmiennych typach wartości, dobrym pomysłem jest implementacja przydatnych interfejsów


Uwagi:

1: Zwróć uwagę, że kompilator może tego użyć podczas wywoływania metod wirtualnych na zmiennych, o których wiadomo, że mają określony typ struktury, ale w przypadku których wymagane jest wywołanie metody wirtualnej. Na przykład:

List<int> l = new List<int>();
foreach(var x in l)
    ;//no-op

Moduł wyliczający zwracany przez List jest strukturą, optymalizacją mającą na celu uniknięcie alokacji podczas wyliczania listy (z interesującymi konsekwencjami ). Jednak semantyka foreach określić, że jeśli narzędzia wyliczający IDisposablenastępnie Dispose()zostanie wywołana po zakończeniu iteracji. Oczywiście, gdyby to nastąpiło za pośrednictwem wywołania pudełkowego, wyeliminowałoby to jakąkolwiek korzyść z faktu, że moduł wyliczający jest strukturą (w rzeczywistości byłoby gorzej). Gorzej, jeśli wywołanie dispose modyfikuje w jakiś sposób stan modułu wyliczającego, to mogłoby się to zdarzyć w pudełkowej instancji i wiele subtelnych błędów może zostać wprowadzonych w złożonych przypadkach. Dlatego IL emitowany w tego rodzaju sytuacji to:

IL_0001: newobj System.Collections.Generic.List..ctor
IL_0006: stloc.0     
IL_0007: nie         
IL_0008: ldloc.0     
IL_0009: callvirt System.Collections.Generic.List.GetEnumerator
IL_000E: stloc.2     
IL_000F: br.s IL_0019
IL_0011: ldloca.s 02 
IL_0013: wywołaj System.Collections.Generic.List.get_Current
IL_0018: stloc.1     
IL_0019: ldloca.s 02 
IL_001B: wywołanie System.Collections.Generic.List.MoveNext
IL_0020: stloc.3     
IL_0021: ldloc.3     
IL_0022: brtrue.s IL_0011
IL_0024: leave.s IL_0035
IL_0026: ldloca.s 02 
IL_0028: ograniczony. System.Collections.Generic.List.Enumerator
IL_002E: callvirt System.IDisposable.Dispose
IL_0033: nie         
IL_0034: koniec końców  

Dlatego implementacja IDisposable nie powoduje żadnych problemów z wydajnością, a (niestety) zmienny aspekt modułu wyliczającego zostaje zachowany, gdyby metoda Dispose faktycznie coś zrobiła!

2: double i float są wyjątkami od tej reguły, gdzie wartości NaN nie są uważane za równe.

ShuggyCoUk
źródło
1
Witryna egheadcafe.com została przeniesiona, ale nie radziła sobie dobrze z zachowaniem jej zawartości. Próbowałem, ale nie mogę znaleźć oryginalnego dokumentu eggheadcafe.com/software/aspnet/31702392/… , bez znajomości OP. (PS +1 za doskonałe podsumowanie).
Abel
2
To świetna odpowiedź, ale myślę, że możesz ją poprawić, przenosząc „Podsumowanie” na górę jako „TL; DR”. Podanie najpierw wniosku pomaga czytelnikowi wiedzieć, dokąd zmierzasz.
Hans
Podczas rzutowania structdo pliku interface.
Jalal
8

W niektórych przypadkach dobrze byłoby, gdyby struktura zaimplementowała interfejs (gdyby nigdy nie był przydatny, wątpliwe jest, aby twórcy .net by to zapewnili). Jeśli struktura implementuje interfejs tylko do odczytu, taki jak IEquatable<T>przechowywanie struktury w miejscu przechowywania (zmienna, parametr, element tablicy itp.), IEquatable<T>Będzie wymagało, aby była zapakowana (każdy typ struktury faktycznie definiuje dwa rodzaje rzeczy: magazyn typ lokalizacji, który zachowuje się jak typ wartości i typ obiektu sterty, który zachowuje się jak typ klasy; pierwszy jest niejawnie konwertowany na drugi - „pudełko” - a drugi może być konwertowany na pierwszy przez jawne rzutowanie - „unboxing”). Możliwe jest wykorzystanie implementacji interfejsu przez strukturę bez boksu, jednak przy użyciu tak zwanych ograniczonych typów generycznych.

Na przykład, gdyby ktoś miał metodę CompareTwoThings<T>(T thing1, T thing2) where T:IComparable<T>, taka metoda mogłaby wywołać thing1.Compare(thing2)bez konieczności zaznaczania thing1lub thing2. Jeśli thing1tak się stanie, np. An, środowisko Int32wykonawcze będzie wiedziało, że generuje kod dla CompareTwoThings<Int32>(Int32 thing1, Int32 thing2). Ponieważ będzie znał dokładny typ obiektu hostującego metodę i tego, który jest przekazywany jako parametr, nie będzie musiał zaznaczać żadnego z nich.

Największym problemem związanym ze strukturami, które implementują interfejsy, jest to, że struktura, która jest przechowywana w lokalizacji typu interfejsu Objectlub ValueType(w przeciwieństwie do lokalizacji własnego typu) zachowuje się jak obiekt klasy. W przypadku interfejsów tylko do odczytu na ogół nie stanowi to problemu, ale w przypadku interfejsu z mutacją, takiego jak IEnumerator<T>ten, może dać dziwną semantykę.

Rozważmy na przykład następujący kod:

List<String> myList = [list containing a bunch of strings]
var enumerator1 = myList.GetEnumerator();  // Struct of type List<String>.IEnumerator
enumerator1.MoveNext(); // 1
var enumerator2 = enumerator1;
enumerator2.MoveNext(); // 2
IEnumerator<string> enumerator3 = enumerator2;
enumerator3.MoveNext(); // 3
IEnumerator<string> enumerator4 = enumerator3;
enumerator4.MoveNext(); // 4

Zaznaczona instrukcja nr 1 spowoduje enumerator1odczytanie pierwszego elementu. Stan tego modułu wyliczającego zostanie skopiowany do enumerator2. Zaznaczona instrukcja nr 2 przesunie tę kopię do odczytania drugiego elementu, ale nie wpłynie na nią enumerator1. Stan tego drugiego modułu wyliczającego zostanie następnie skopiowany do enumerator3, który zostanie przesunięty o zaznaczoną instrukcję nr 3. Wtedy, ponieważ enumerator3i enumerator4są oba typy referencyjną, mającej na enumerator3zostanie skopiowany do enumerator4, tak oznakowane oświadczenie będzie skutecznie awansować zarówno enumerator3 i enumerator4.

Niektórzy próbują udawać, że typy wartości i typy referencyjne są jednymi i drugimi Object, ale to nieprawda. Rzeczywiste typy wartości można zamieniać na Object, ale nie są to instancje. Instancja, List<String>.Enumeratorktóra jest przechowywana w lokalizacji tego typu, jest typem wartości i zachowuje się jak typ wartości; skopiowanie go do lokalizacji typu IEnumerator<String>spowoduje przekonwertowanie go na typ referencyjny i będzie zachowywał się jak typ referencyjny . Ten ostatni jest rodzajem Object, ale ten pierwszy nie.

Przy okazji, jeszcze kilka uwag: (1) Ogólnie rzecz biorąc, zmienne typy klas powinny mieć swoje Equalsmetody testujące równość odwołań, ale nie ma na to przyzwoitego sposobu, aby to zrobić w strukturze pudełkowej; (2) pomimo swojej nazwy ValueTypejest typem klasowym, a nie wartościowym; wszystkie typy pochodne od System.Enumsą typami wartości, podobnie jak wszystkie typy, które pochodzą od, ValueTypez wyjątkiem System.Enum, ale oba ValueTypei System.Enumsą typami klasowymi.

supercat
źródło
3

Struktury są implementowane jako typy wartości, a klasy są typami referencyjnymi. Jeśli masz zmienną typu Foo i przechowujesz w niej instancję Fubar, będzie ona „opakowywać” ją w typ referencyjny, co niweczy korzyści wynikające z używania struktury w pierwszej kolejności.

Jedynym powodem, dla którego widzę użycie struktury zamiast klasy, jest to, że będzie to typ wartości, a nie typ referencyjny, ale struktura nie może dziedziczyć z klasy. Jeśli struktura dziedziczy interfejs i przekazujesz interfejsy dookoła, tracisz naturę struktury typu wartości. Równie dobrze może po prostu uczynić z niej klasę, jeśli potrzebujesz interfejsów.

dotnetengineer
źródło
Czy to działa tak samo dla prymitywów, które implementują również interfejsy?
aoetalks
3

(Cóż, nie mam nic ważnego do dodania, ale nie mam jeszcze umiejętności edycji, więc proszę ...)
Całkowicie bezpieczny. Nie ma nic nielegalnego w implementowaniu interfejsów w strukturach. Jednak powinieneś zapytać, dlaczego chcesz to zrobić.

Jednak uzyskanie referencji interfejsu do struktury spowoduje jej BOX . A więc spadek wydajności i tak dalej.

Jedyny prawidłowy scenariusz, o którym mogę teraz pomyśleć, jest zilustrowany w moim poście tutaj . Jeśli chcesz zmodyfikować stan struktury przechowywany w kolekcji, musisz to zrobić za pośrednictwem dodatkowego interfejsu uwidocznionego w strukturze.

Gishu
źródło
Jeśli przekaże się an Int32do metody, która akceptuje typ ogólny T:IComparable<Int32>(który może być albo parametrem typu ogólnego metody, albo klasą metody), ta metoda będzie mogła użyć Comparemetody na przekazanym obiekcie bez pakowania w ramki.
supercat
1

Myślę, że problem polega na tym, że powoduje to boksowanie, ponieważ struktury są typami wartości, więc istnieje niewielki spadek wydajności.

Ten link sugeruje, że mogą występować z nim inne problemy ...

http://blogs.msdn.com/abhinaba/archive/2005/10/05/477238.aspx

Simon Keep
źródło
0

Struktura implementująca interfejs nie ma żadnych konsekwencji. Na przykład wbudowane struktury systemu implementują interfejsy takie jak IComparablei IFormattable.

Joseph Daigle
źródło
0

Istnieje bardzo niewiele powodów, dla których typ wartości powinien implementować interfejs. Ponieważ nie można podklasować typu wartości, zawsze można odwołać się do niego jako do konkretnego typu.

O ile oczywiście nie masz wielu struktur implementujących ten sam interfejs, może to być wtedy marginalnie przydatne, ale w tym momencie zalecałbym użycie klasy i zrobienie tego dobrze.

Oczywiście, implementując interfejs, pakujesz strukturę, więc teraz siedzi na stercie i nie będziesz już w stanie przekazać jej przez wartość ... To naprawdę wzmacnia moją opinię, że powinieneś po prostu użyć klasy w tej sytuacji.

FlySwat
źródło
Jak często podajesz IComparable zamiast konkretnej implementacji?
FlySwat
Nie musisz podawać IComparablewartości w ramce. Po prostu wywołując metodę, która oczekuje IComparablez typem wartości, który ją implementuje, niejawnie zapakujesz typ wartości.
Andrew Hare
1
@AndrewHare: Ograniczone typy generyczne pozwalają na IComparable<T>wywoływanie metod na strukturach typu Tbez pakowania.
supercat
-10

Struktury są jak klasy żyjące na stosie. Nie widzę powodu, dla którego miałyby być „niebezpieczne”.

Sklivvz
źródło
Z wyjątkiem braku dziedzictwa.
FlySwat
7
Nie zgadzam się z każdą częścią tej odpowiedzi; oni nie muszą żyć na stosie, a kopia-semantyka są bardzo różni się do klas.
Marc Gravell
1
Są niezmienne, nadmierne użycie struktury
zasmuci
1
@Teomanshipahi Nadmierne użycie instancji klas doprowadzi do szału.
IllidanS4 obsługuje Monikę
5
Dla kogoś, kto ma ponad 20 tys. Powtórzeń, ta odpowiedź jest po prostu nie do przyjęcia.
Krythic