Dlaczego w języku C # ciąg jest typem referencyjnym, który zachowuje się jak typ wartości?

371

Łańcuch jest typem odniesienia, mimo że ma większość cech typu wartości, takich jak niezmienność i przeciążenie == w celu porównania tekstu zamiast upewnienia się, że odnoszą się do tego samego obiektu.

Dlaczego zatem łańcuch nie jest tylko typem wartości?

Davy8
źródło
Ponieważ w przypadku typów niezmiennych rozróżnienie dotyczy głównie szczegółów implementacyjnych (pomijając istesty), odpowiedź jest prawdopodobnie „z przyczyn historycznych”. Wydajność kopiowania nie może być przyczyną, ponieważ nie ma potrzeby fizycznego kopiowania niezmiennych obiektów. Teraz nie można zmienić bez zerwania kodu, który faktycznie używa isczeków (lub podobnych ograniczeń).
Elazar
BTW, to jest ta sama odpowiedź dla C ++ (chociaż rozróżnienie między typem wartości a typem referencyjnym nie jest jednoznaczne w języku), decyzja o std::stringzachowaniu się jak kolekcja jest starym błędem, którego nie można teraz naprawić.
Elazar

Odpowiedzi:

333

Ciągi nie są typami wartości, ponieważ mogą być ogromne i muszą być przechowywane na stercie. Typy wartości są (we wszystkich implementacjach CLR jak dotąd) przechowywane na stosie. Ciągi alokujące stos psują różne rzeczy: stos ma tylko 1 MB dla wersji 32-bitowej i 4 MB dla wersji 64-bitowej, będziesz musiał umieścić każdy łańcuch, ponosząc karę za kopiowanie, nie możesz internować łańcuchów i użycia pamięci balonem itp.

(Edycja: Dodano wyjaśnienie na temat przechowywania typów wartości będących szczegółami implementacji, co prowadzi do sytuacji, w której mamy typ z wartościami matematycznymi nie dziedziczącymi po System.ValueType. Dzięki Ben.)

codekaizen
źródło
75
Grzebię tutaj, ale tylko dlatego, że daje mi to link do postu na blogu związanego z pytaniem: typy wartości niekoniecznie są przechowywane na stosie. Jest to najczęściej prawdziwe w ms.net, ale wcale nie jest określone w specyfikacji CLI. Główną różnicą między typami wartości i referencji jest to, że typy referencji są zgodne z semantyką kopiowania według wartości. Zobacz blogs.msdn.com/ericlippert/archive/2009/04/27/… i blogs.msdn.com/ericlippert/archive/2009/05/04/...
Ben Schwehn
8
@Qwertie: Stringnie ma zmiennej wielkości. Po dodaniu do niego w rzeczywistości tworzysz inny Stringobiekt, przydzielając mu nową pamięć.
codekaizen
5
To powiedziawszy, ciąg może teoretycznie być typem wartości (strukturą), ale „wartość” byłaby niczym innym jak odniesieniem do łańcucha. Projektanci platformy .NET w naturalny sposób postanowili wyeliminować pośrednika (obsługa struktury była nieefektywna w .NET 1.0, i było naturalne, że śledzono Javę, w której łańcuchy zostały już zdefiniowane jako odwołanie, a nie prymitywny typ. Plus, jeśli łańcuch był typ wartości, a następnie konwersja go na obiekt wymagałaby jego spakowania, co byłoby niepotrzebną nieefektywnością).
Qwertie,
7
@codekaizen Qwertie ma rację, ale myślę, że sformułowanie było mylące. Jeden ciąg może mieć inny rozmiar niż inny ciąg, a zatem, w przeciwieństwie do prawdziwego typu wartości, kompilator nie może wcześniej wiedzieć, ile miejsca należy przeznaczyć na przechowanie wartości ciągu. Na przykład an Int32ma zawsze 4 bajty, dlatego kompilator przydziela 4 bajty za każdym razem, gdy definiujesz zmienną łańcuchową. Ile pamięci powinien przydzielić kompilator, gdy napotka intzmienną (jeśli byłaby to typ wartości)? Zrozum, że wartość nie została jeszcze przypisana w tym czasie.
Kevin Brock,
2
Przepraszam, literówka w moim komentarzu, której nie mogę teraz naprawić; tak powinno być… Na przykład an Int32ma zawsze 4 bajty, dlatego kompilator przydziela 4 bajty za każdym razem, gdy definiujesz intzmienną. Ile pamięci powinien przydzielić kompilator, gdy napotka stringzmienną (gdyby był to typ wartości)? Zrozum, że wartość nie została jeszcze przypisana w tym czasie.
Kevin Brock
57

Nie jest to typ wartości, ponieważ wydajność (przestrzeń i czas!) Byłaby straszna, gdyby był typem wartości, a jego wartość musiałaby być kopiowana za każdym razem, gdy był przekazywany i zwracany z metod itp.

Ma semantykę wartości, aby utrzymać świat przy zdrowych zmysłach. Czy możesz sobie wyobrazić, jak trudno byłoby kodować, jeśli

string s = "hello";
string t = "hello";
bool b = (s == t);

ustawić bsię false? Wyobraź sobie, jak trudne byłoby kodowanie w prawie każdej aplikacji.

Jason
źródło
44
Java nie jest znana z tego, że jest zwięzła.
jason
3
@Matt: dokładnie. Kiedy przełączyłem się na C #, było to trochę mylące, ponieważ zawsze używałem (i czasami jeszcze) równości (..) do porównywania ciągów, podczas gdy moi koledzy z drużyny używali po prostu „==”. Nigdy nie zrozumiałem, dlaczego nie zostawili znaku „==” w celu porównania referencji, chociaż, jeśli myślisz, w 90% przypadków prawdopodobnie będziesz chciał porównać zawartość, a nie referencje dla ciągów.
Juri
7
@Juri: Właściwie uważam, że nigdy nie jest pożądane sprawdzanie referencji, ponieważ czasami new String("foo");i inni new String("foo")mogą oceniać w tym samym referencji, jakiego rodzaju nie jest to, czego oczekuje się od newoperatora. (Czy możesz mi powiedzieć przypadek, w którym chciałbym porównać odniesienia?)
Michael
1
@Michael Cóż, musisz uwzględnić porównanie referencyjne we wszystkich porównaniach, aby złapać porównanie z wartością null. Innym dobrym miejscem do porównywania referencji z łańcuchami jest porównywanie zamiast porównywania równości. Dwa równoważne ciągi, gdy są porównywane, powinny zwracać 0. Sprawdzanie tego przypadku trwa jednak tak długo, jak przeglądanie całego porównania, więc nie jest to przydatny skrót. Sprawdzanie ReferenceEquals(x, y)jest szybkim testem i możesz natychmiast zwrócić 0, a po zmieszaniu z testem zerowym nie dodaje on nawet więcej pracy.
Jon Hanna,
1
... posiadanie ciągów znaków jako typu wartości tego stylu zamiast bycia typem klasy oznaczałoby, że domyślna wartość a stringmogłaby zachowywać się jak pusty ciąg znaków (jak w systemach wcześniejszych niż.net), a nie jako odwołanie zerowe. Właściwie, wolę mieć typ wartości, Stringktóry zawiera typ referencyjny NullableString, przy czym ten pierwszy ma wartość domyślną równoważną, String.Emptya drugi domyślną null, i ze specjalnymi regułami boksu / rozpakowania (takimi jak boksowanie domyślnego- wyceniony NullableStringdałby odniesienie do String.Empty).
supercat,
26

Rozróżnienie między typami referencyjnymi a typami wartości jest w zasadzie kompromisem wydajnościowym w projekcie języka. Typy referencyjne mają pewne koszty związane z budową i zniszczeniem oraz wyrzucaniem elementów bezużytecznych, ponieważ są tworzone na stercie. Z drugiej strony typy wartości mają narzut na wywołania metod (jeśli rozmiar danych jest większy niż wskaźnik), ponieważ cały obiekt jest kopiowany, a nie tylko wskaźnik. Ponieważ łańcuchy mogą być (i zwykle są) znacznie większe niż rozmiar wskaźnika, są one zaprojektowane jako typy referencyjne. Ponadto, jak wskazał Servy, rozmiar typu wartości musi być znany w czasie kompilacji, co nie zawsze ma miejsce w przypadku łańcuchów.

Kwestia zmienności jest osobną kwestią. Zarówno typy referencyjne, jak i typy wartości mogą być zmienne lub niezmienne. Typy wartości są jednak zwykle niezmienne, ponieważ semantyka zmiennych typów może być myląca.

Typy referencyjne są generalnie zmienne, ale można je zaprojektować jako niezmienne, jeśli ma to sens. Ciągi są zdefiniowane jako niezmienne, ponieważ umożliwia pewne optymalizacje. Na przykład, jeśli ten sam literał ciągu występuje wiele razy w tym samym programie (co jest dość powszechne), kompilator może ponownie użyć tego samego obiektu.

Dlaczego więc „==” jest przeciążone, aby porównać ciągi tekstowe? Ponieważ jest to najbardziej przydatna semantyka. Jeśli dwa ciągi tekstowe są równe, mogą, ale nie muszą być tym samym odwołaniem do obiektu ze względu na optymalizacje. Porównywanie referencji jest więc bezużyteczne, a porównywanie tekstu prawie zawsze jest tym, czego chcesz.

Mówiąc bardziej ogólnie, ciągi mają tak zwaną semantykę wartości . Jest to koncepcja bardziej ogólna niż typy wartości, która jest szczegółem implementacji specyficznym dla języka C #. Typy wartości mają semantykę wartości, ale typy referencyjne mogą również mieć semantykę wartości. Gdy typ ma semantykę wartości, nie można tak naprawdę stwierdzić, czy podstawowa implementacja jest typem referencyjnym czy typem wartości, więc można uznać, że szczegół implementacji.

JacquesB
źródło
Różnica między typami wartości a typami referencyjnymi wcale tak naprawdę nie dotyczy wydajności. Chodzi o to, czy zmienna zawiera rzeczywisty obiekt lub odniesienie do obiektu. Łańcuch nigdy nie mógłby być typem wartości, ponieważ rozmiar łańcucha jest zmienny; musiałby być stały, aby był typem wartości; wydajność nie ma z tym prawie nic wspólnego. Tworzenie typów referencyjnych nie jest wcale drogie.
Servy
2
@Sevy: Rozmiar łańcucha jest stały.
JacquesB,
Ponieważ zawiera tylko odwołanie do tablicy znaków o zmiennej wielkości. Posiadanie typu wartości, który jest jedyną prawdziwą „wartością”, jest typem odniesienia, tym bardziej zagmatwa, ponieważ nadal miałby semantykę odniesienia dla wszystkich intensywnych celów.
Servy
1
@Sevy: Rozmiar tablicy jest stały.
JacquesB,
1
Po utworzeniu tablicy jej rozmiar jest stały, ale wszystkie tablice na całym świecie nie są dokładnie tego samego rozmiaru. To mój punkt. Aby ciąg znaków był typem wartości, wszystkie istniejące ciągi musiałyby mieć dokładnie taki sam rozmiar, ponieważ w ten sposób typy wartości są projektowane w .NET. Musi być w stanie zarezerwować miejsce na takie typy wartości, zanim faktycznie uzyska wartość , więc rozmiar musi być znany w czasie kompilacji . Taki stringtyp musiałby mieć bufor char o pewnym stałym rozmiarze, który byłby zarówno restrykcyjny, jak i wysoce nieefektywny.
Servy
16

To późna odpowiedź na stare pytanie, ale wszystkie inne odpowiedzi nie mają sensu, to znaczy, że .NET nie miał generycznych aż do .NET 2.0 w 2005 roku.

Stringjest typem referencyjnym zamiast typu wartości, ponieważ dla Microsoftu kluczowe znaczenie miało zapewnienie, aby ciągi mogły być przechowywane w najbardziej efektywny sposób w kolekcjach innych niż ogólne , takich jak System.Collections.ArrayList.

Przechowywanie typu wartości w kolekcji innej niż ogólna wymaga specjalnej konwersji na typ objectnazywany boksem. Gdy CLR zawiera typ wartości, otacza wartość wewnątrz a System.Objecti przechowuje ją na zarządzanej stercie.

Odczyt wartości z kolekcji wymaga operacji odwrotnej, która nazywa się rozpakowaniem.

Zarówno boks, jak i rozpakowanie mają niemały wpływ: boks wymaga dodatkowego przydziału, rozpakowanie wymaga sprawdzenia typu.

Niektóre odpowiedzi twierdzą, że niepoprawnie stringnie mogły zostać zaimplementowane jako typ wartości, ponieważ jej rozmiar jest zmienny. W rzeczywistości łatwo jest zaimplementować ciąg jako strukturę danych o stałej długości przy użyciu strategii optymalizacji małych ciągów: ciągi byłyby przechowywane w pamięci bezpośrednio jako sekwencja znaków Unicode, z wyjątkiem dużych ciągów, które byłyby przechowywane jako wskaźnik do bufora zewnętrznego. Obie reprezentacje można zaprojektować tak, aby miały tę samą stałą długość, tj. Rozmiar wskaźnika.

Gdyby istniały generyczne od samego początku, prawdopodobnie ciąg znaków jako typ wartości byłby prawdopodobnie lepszym rozwiązaniem, z prostszą semantyką, lepszym wykorzystaniem pamięci i lepszą lokalizacją pamięci podręcznej. A List<string>zawierające tylko małe struny mogło być jeden ciągły blok pamięci.

ZunTzu
źródło
Dziękuję za tę odpowiedź! Patrzyłem na wszystkie inne odpowiedzi mówiące o przydziałach sterty i stosu, podczas gdy stos jest szczegółem implementacji . W końcu i tak stringzawiera tylko swój rozmiar i wskaźnik do chartablicy, więc nie byłby to „ogromny typ wartości”. Ale jest to prosty, istotny powód tej decyzji projektowej. Dzięki!
V0ldek
8

Nie tylko łańcuchy są niezmiennymi typami referencyjnymi. Delegaci z wielu obsad także. Dlatego można bezpiecznie pisać

protected void OnMyEventHandler()
{
     delegate handler = this.MyEventHandler;
     if (null != handler)
     {
        handler(this, new EventArgs());
     }
}

Przypuszczam, że ciągi są niezmienne, ponieważ jest to najbezpieczniejsza metoda pracy z nimi i przydzielania pamięci. Dlaczego nie są to typy wartości? Poprzedni autorzy mają rację co do wielkości stosu itp. Dodałbym również, że tworzenie ciągów jako typów referencyjnych pozwala zaoszczędzić na rozmiarze zestawu, gdy używasz tego samego stałego ciągu w programie. Jeśli zdefiniujesz

string s1 = "my string";
//some code here
string s2 = "my string";

Możliwe, że oba wystąpienia stałej „mój ciąg” zostaną przydzielone w twoim zestawie tylko raz.

Jeśli chcesz zarządzać ciągami jak zwykle typem odwołania, umieść ciąg w nowym StringBuilder (ciągach). Lub użyj MemoryStreams.

Jeśli chcesz utworzyć bibliotekę, w której oczekujesz, że w twoich funkcjach będą przekazywane ogromne ciągi, zdefiniuj parametr jako StringBuilder lub jako Stream.

Bogdan_Ch
źródło
1
Istnieje wiele przykładów niezmiennych typów referencyjnych. I znowu przykład struny, który jest rzeczywiście prawie gwarantowany w obecnych implementacjach - technicznie jest to na moduł (nie na montaż) - ale to prawie zawsze to samo ...
Marc Gravell
5
Odnośnie ostatniego punktu: StringBuilder nie pomaga, jeśli próbujesz przekazać duży ciąg (ponieważ tak naprawdę jest on zaimplementowany jako ciąg) - StringBuilder przydaje się do wielokrotnego manipulowania ciągiem.
Marc Gravell
Czy miałeś na myśli program obsługi delegata, a nie hadlera? (przepraszam, że jestem wybredny ... ale to bardzo blisko (nieczęstego) nazwiska, które znam ...)
Pure.Krome
6

Ponadto sposób implementacji ciągów znaków (inny dla każdej platformy) i rozpoczęcie ich łączenia. Jak za pomocą StringBuilder. Przydziela bufor do skopiowania, gdy dotrzesz do końca, przydziela ci jeszcze więcej pamięci, mając nadzieję, że jeśli wykonasz dużą konkatenację, wydajność nie będzie utrudniona.

Może Jon Skeet może tu pomóc?

Chris
źródło
5

Jest to głównie problem z wydajnością.

Posługiwanie się łańcuchami typu LIKE pomaga w pisaniu kodu, ale posiadanie go BE typu wartości spowodowałoby ogromny spadek wydajności.

Aby uzyskać dogłębne spojrzenie, rzuć okiem na fajny artykuł na temat ciągów znaków w środowisku .net.

Denis Troller
źródło
3

W bardzo prostych słowach każdą wartość, która ma określony rozmiar, można traktować jako typ wartości.

saurav.net
źródło
To powinien być komentarz
ρяσсρєя K
łatwiej zrozumieć dla ppl nowość na c #
LONG
2

Jak stringrozpoznać typ odniesienia? Nie jestem pewien, czy ma to znaczenie, jak to jest realizowane. Ciągi w C # są niezmienne, więc nie musisz się martwić tym problemem.


źródło
Jest to typ odwołania (uważam), ponieważ nie pochodzi on od System.ValueType Od MSDN Uwagi na temat System.ValueType: Typy danych są podzielone na typy wartości i typy referencji. Typy wartości są albo alokowane na stos, albo alokowane w strukturze. Typy referencyjne są alokowane na stercie.
Davy8
Zarówno odwołania, jak i typy wartości pochodzą z ostatecznej klasy podstawowej Object. W przypadkach, w których konieczne jest, aby typ wartości zachowywał się jak obiekt, otulina, która sprawia, że ​​typ wartości wygląda jak obiekt referencyjny, jest przydzielana na stercie, a wartość typu wartości jest kopiowana do niego.
Davy8
Opakowanie jest oznaczone, aby system wiedział, że zawiera typ wartości. Ten proces jest znany jako boks, a proces odwrotny to unboxing. Boksowanie i rozpakowywanie pozwala na traktowanie dowolnego typu jako obiektu. (Na tylnej stronie prawdopodobnie powinienem był po prostu link do artykułu.)
Davy8
2

W rzeczywistości łańcuchy mają bardzo niewiele podobieństw do typów wartości. Na początek, nie wszystkie typy wartości są niezmienne, możesz zmienić wartość Int32, jak chcesz, i nadal będzie to ten sam adres na stosie.

Ciągi są niezmienne z bardzo dobrego powodu, nie ma to nic wspólnego z tym, że jest typem referencyjnym, ale ma wiele wspólnego z zarządzaniem pamięcią. Po prostu bardziej efektywne jest tworzenie nowego obiektu, gdy zmienia się rozmiar łańcucha, niż przenoszenie rzeczy na zarządzanej stercie. Myślę, że łączysz ze sobą typy wartości / referencji i niezmienne obiekty.

O ile „==”: tak jak powiedziałeś „==” to przeciążenie operatora, i ponownie zostało zaimplementowane z bardzo dobrego powodu, aby uczynić strukturę bardziej przydatną podczas pracy z łańcuchami.

WebMatrix
źródło
Zdaję sobie sprawę, że typy wartości nie są z definicji niezmienne, ale wydaje się, że większość najlepszych praktyk sugeruje, że powinny być takie przy tworzeniu własnych. Powiedziałem, że cechy, a nie właściwości typów wartości, co dla mnie oznacza, że ​​często typy wartości wykazują je, ale niekoniecznie z definicji
Davy8
5
@WebMatrix, @ Davy8: Typy pierwotne (int, double, bool, ...) są niezmienne.
jason
1
@Jason, myślałem, że niezmienne określenie dotyczy głównie obiektów (typów referencji), które nie mogą się zmienić po inicjalizacji, takich jak łańcuchy, gdy zmienia się wartość łańcucha, wewnętrznie tworzona jest nowa instancja łańcucha, a oryginalny obiekt pozostaje niezmieniony. Jak to się odnosi do typów wartości?
WebMatrix
8
Jakoś w „int n = 4; n = 9;” nie jest tak, że twoja zmienna int jest „niezmienna” w sensie „stała”; jest to, że wartość 4 jest niezmienna, nie zmienia się na 9. Twoja zmienna int „n” najpierw ma wartość 4, a następnie inną wartość, 9; ale same wartości są niezmienne. Szczerze mówiąc, dla mnie jest to bardzo zbliżone do wtf.
Daniel Daranas
1
+1. Mam dość słuchania, że ​​„łańcuchy są jak typy wartości”, kiedy po prostu nie są.
Jon Hanna
1

To nie jest tak proste, jak Ciągi składają się z tablic znaków. Patrzę na ciągi jako tablice znaków []. Dlatego znajdują się na stercie, ponieważ referencyjna lokalizacja pamięci jest przechowywana na stosie i wskazuje początek lokalizacji pamięci tablicy na stercie. Rozmiar łańcucha nie jest znany przed przydzieleniem ... idealny dla sterty.

Właśnie dlatego ciąg znaków jest niezmienny, ponieważ kiedy go zmienisz, nawet jeśli ma ten sam rozmiar, kompilator nie wie o tym i musi przydzielić nową tablicę i przypisać znaki do pozycji w tablicy. Ma to sens, jeśli myślisz o ciągach jako sposobie, w jaki języki chronią cię przed koniecznością alokacji pamięci w locie (czytaj C jak programowanie)

BionicCyborg
źródło
1
„rozmiar łańcucha nie jest znany przed przydzieleniem” - jest to niepoprawne w CLR.
codekaizen
-1

Ryzykuje kolejne tajemnicze głosowanie w dół ... fakt, że wielu wspomina o stosie i pamięci w odniesieniu do typów wartości i typów pierwotnych, ponieważ muszą zmieścić się w rejestrze mikroprocesora. Nie możesz pchać ani wrzucać czegoś do / ze stosu, jeśli zajmuje więcej bitów niż rejestr ma .... instrukcje to na przykład „pop eax” - ponieważ eax ma 32 bity szerokości w systemie 32-bitowym.

Typy pierwotne zmiennoprzecinkowe są obsługiwane przez FPU o szerokości 80 bitów.

To wszystko zostało postanowione na długo przed pojawieniem się języka OOP, który zaciemniałby definicję typu pierwotnego i zakładam, że typ wartości jest terminem, który został stworzony specjalnie dla języków OOP.

Jinzai
źródło