Articles

C++ pointery: dlaczego ich potrzebujemy, kiedy ich używamy, czym się różnią od dostępu do samego obiektu

Mimo że większość programistów rozumie, jaka jest różnica między obiektami a wskaźnikami do nich, czasami nie jest do końca jasne, który ze sposobów dostępu do obiektu wybrać. Postaraliśmy się odpowiedzieć na to pytanie poniżej.

Pytanie

Pochodzę z środowiska Java i zacząłem pracować z obiektami w C++. Ale jedną rzeczą, która przyszła mi do głowy jest to, że ludzie często używają wskaźników do obiektów zamiast samych obiektów, na przykład ta deklaracja:

Object *myObject = new Object;

zamiast:

Object myObject;

Albo zamiast używać funkcji, powiedzmy testFunc(), jak to:

myObject.testFunc();

musimy napisać:

myObject->testFunc();

Ale nie mogę wymyślić, dlaczego powinniśmy to robić w ten sposób. Zakładam, że ma to związek z wydajnością i szybkością, ponieważ uzyskujemy bezpośredni dostęp do adresu pamięci. Czy mam rację?

Answer

Przy okazji należy zauważyć, że wskaźniki w Javie nie są używane jawnie, np. programista nie może uzyskać dostępu do obiektu w kodzie poprzez wskaźnik do niego. Natomiast w Javie wszystkie typy, poza bazowymi, są referencjonowane: dostęp do nich odbywa się przez odnośnik, chociaż nie można jawnie przekazać parametru przez odnośnik. Poza tym new w C++ i Java czy C# to różne rzeczy.

Aby dać lekkie pojęcie o wskaźnikach w C++ , podamy dwa podobne fragmenty kodu:

Java:

Object object1 = new Object(); //A new object is allocated by JavaObject object2 = new Object(); //Another new object is allocated by Javaobject1 = object2; //object1 now points to the object originally allocated for object2//The object originally allocated for object1 is now "dead" – //nothing points to it, so it//will be reclaimed by the Garbage Collector.//If either object1 or object2 is changed, //the change will be reflected to the other

Najbliższym odpowiednikiem tego, jest:

C++:

Object * object1 = new Object(); //A new object is allocated on the heapObject * object2 = new Object(); //Another new object is allocated on the heapdelete object1;//Since C++ does not have a garbage collector, //if we don't do that, the next line would //cause a "memory leak", i.e. a piece of claimed memory that //the app cannot use //and that we have no way to reclaim...object1 = object2; //Same as Java, object1 points to object2.

Zobaczmy alternatywny sposób C++:

Object object1; //A new object is allocated on the STACKObject object2; //Another new object is allocated on the STACKobject1 = object2;//!!!! This is different! //The CONTENTS of object2 are COPIED onto object1,//using the "copy assignment operator", the definition of operator =.//But, the two objects are still different. //Change one, the other remains unchanged.//Also, the objects get automatically destroyed //once the function returns...

Czy zyskujemy na szybkości, uzyskując dostęp bezpośrednio do pamięci?

Właściwie wcale nie. Pointery są zwykle używane do dostępu do sterty, podczas gdy obiekty znajdują się na stosie – jest to prostsza i szybsza struktura. Jeśli jesteś początkujący, mamy dla Ciebie materiał, w którym szczegółowo opowiemy, czym jest stos i sterta.

Prawdę mówiąc, to pytanie łączy w sobie dwa różne zagadnienia. Po pierwsze: kiedy korzystać z dynamicznej alokacji pamięci? Po drugie: kiedy lepiej używać wskaźników? Jasne, nie obejdzie się bez utartych słów, że zawsze należy wybierać najodpowiedniejsze narzędzie do danej pracy. Prawie zawsze istnieje lepsza realizacja niż użycie ręcznej dynamicznej alokacji (dynamic allocation) i/lub surowych wskaźników (raw pointers).

To bardzo niefortunne, że tak często widzisz dynamiczną alokację. To tylko pokazuje, jak wielu złych programistów C ++ istnieje.

W pewnym sensie masz dwa pytania połączone w jedno. Pierwsze to kiedy powinniśmy używać dynamicznej alokacji (używając new)? Drugie to kiedy powinniśmy używać wskaźników?

Ważnym przesłaniem jest to, że zawsze powinieneś używać odpowiedniego narzędzia do pracy. W prawie wszystkich sytuacjach istnieje coś bardziej odpowiedniego i bezpieczniejszego niż ręczne wykonywanie dynamicznej alokacji i/lub używanie surowych wskaźników.

Dynamiczna alokacja

W twoim pytaniu zademonstrowałeś dwa sposoby tworzenia obiektu. Główną różnicą jest czas przechowywania obiektu. Podczas wykonywania Object myObject; wewnątrz bloku, obiekt jest tworzony z automatycznym czasem przechowywania, co oznacza, że zostanie zniszczony automatycznie, gdy wyjdzie poza zakres. Kiedy wykonujesz new Object(), obiekt ma dynamiczny czas przechowywania, co oznacza, że pozostaje żywy dopóki nie zostanie jawnie usunięty. Powinieneś używać dynamicznego czasu przechowywania tylko wtedy, gdy tego potrzebujesz. Oznacza to, że zawsze powinieneś preferować tworzenie obiektów z automatycznym czasem przechowywania, kiedy możesz.

Główne dwie sytuacje, w których możesz wymagać dynamicznej alokacji:

  1. Potrzebujesz obiektu, aby przekroczyć bieżący zakres – ten konkretny obiekt w tej konkretnej lokalizacji pamięci, a nie jego kopia. Jeśli jesteś w porządku z kopiowaniem / przenoszeniem obiektu (przez większość czasu powinieneś być), powinieneś preferować automatyczny obiekt.
  2. Musisz przydzielić dużo pamięci, która może łatwo wypełnić stos. Byłoby miło, gdybyśmy nie musieli się tym przejmować (w większości przypadków nie powinieneś), ponieważ tak naprawdę jest to poza zasięgiem C++, ale niestety musimy radzić sobie z realiami systemów, dla których tworzymy.
  3. Nie znasz dokładnie rozmiaru tablicy, której będziesz musiał użyć. Jak wiesz, w C++ rozmiar tablic jest stały. Może to powodować problemy np. podczas odczytywania danych wprowadzanych przez użytkownika. Wskaźnik definiuje tylko ten fragment pamięci, gdzie będzie zapisany początek tablicy, nie ograniczając jej rozmiaru.

Jeśli konieczne jest użycie dynamicznej alokacji, należy ją obudować za pomocą inteligentnego wskaźnika lub innego typu, który wspiera idiom „Pozyskiwanie zasobów jest inicjalizacją” (standardowe kontenery to wspierają – jest to idiom, zgodnie z którym zasoby: blok pamięci, plik, połączenie sieciowe, itp. są inicjalizowane podczas uzyskiwania w konstruktorze, a następnie ostrożnie niszczone przez destruktor). Na przykład, inteligentne wskaźniki są std::unique_ptr i std::shared_ptr

Pointers

Jednakże istnieją inne bardziej ogólne zastosowania surowych wskaźników poza dynamiczną alokacją, ale większość ma alternatywy, które powinieneś preferować. Jak poprzednio, zawsze preferuj alternatywy, chyba że naprawdę potrzebujesz wskaźników.

  1. Potrzebujesz semantyki referencyjnej. Czasami chcesz przekazać obiekt za pomocą wskaźnika (niezależnie od tego, jak został przydzielony), ponieważ chcesz, aby funkcja, do której go przekazujesz, miała dostęp do tego konkretnego obiektu (a nie jego kopii). Jednakże, w większości sytuacji, powinieneś preferować typy referencyjne niż wskaźniki, ponieważ to jest właśnie to, do czego zostały zaprojektowane. Zauważ, że niekoniecznie chodzi tu o przedłużenie czasu życia obiektu poza bieżący zakres, jak w sytuacji 1 powyżej. Tak jak wcześniej, jeśli jesteś w porządku z przekazywaniem kopii obiektu, nie potrzebujesz semantyki referencyjnej.
  2. Potrzebujesz polimorfizmu. Możesz wywoływać funkcje tylko polimorficznie (to znaczy zgodnie z typem dynamicznym obiektu) za pośrednictwem wskaźnika lub odniesienia do obiektu. Jeśli takie jest zachowanie, którego potrzebujesz, to musisz użyć wskaźników lub referencji. Ponownie, referencje powinny być preferowane.
  3. Chcesz reprezentować, że obiekt jest opcjonalny, pozwalając na przekazanie nullptr, gdy obiekt jest pomijany. Jeśli jest to argument, powinieneś preferować używanie domyślnych argumentów lub przeciążanie funkcji. W przeciwnym razie powinieneś preferować użycie typu, który enkapsuluje to zachowanie, takie jak std::optional (wprowadzone w C ++ 17 – z wcześniejszymi standardami C ++, użyj boost::optional).
  4. Chcesz oddzielić jednostki kompilacji, aby poprawić czas kompilacji. Użyteczną właściwością wskaźnika jest to, że wymagana jest tylko deklaracja forward typu wskazywanego (aby faktycznie użyć obiektu, będziesz potrzebował definicji). Pozwala to na odłączenie części procesu kompilacji, co może znacznie poprawić czas kompilacji. Zobacz idiom Pimpl.
  5. Potrzebujesz interfejsu z biblioteką C lub biblioteką w stylu C. W tym momencie jesteś zmuszony do używania surowych wskaźników. Najlepszą rzeczą, jaką możesz zrobić, to upewnić się, że wypuszczasz swoje surowe wskaźniki tylko w ostatnim możliwym momencie. Możesz uzyskać surowy wskaźnik z inteligentnego wskaźnika, na przykład, używając jego funkcji członkowskiej get. Jeśli biblioteka wykonuje dla ciebie alokację, którą oczekuje od ciebie deallokacji poprzez uchwyt, często możesz zawinąć uchwyt w inteligentny wskaźnik z niestandardowym deleterem, który odpowiednio deallokuje obiekt.