Semantyka przenoszenia w C++ - trzy pytania

Semantyka przenoszenia w C++ - trzy pytania
whiteman808
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 201
0

Hej,

Dlaczego w przypadku używania std::move nie tworzy się osobnych konstruktorów tak jak pokazano to w // 1 i w // 2 tylko zwyczajnie używa się przekazywania przez wartość?

Przypadek // 1 w którym przekazujemy zwykłe l-wartości jestem w stanie zrozumieć, wtedy po prostu konwertujemy f i l za pomocą std::move na r-wartości i wywołujemy odpowiednie konstruktory przenoszące odpowiadających im klas skoro mamy r-wartości (std::move dokonuje konwersji przekazanego mu argumentu na r-wartość żeby można było go przenieść).

Nie rozumiem natomiast tego czemu nie tworzymy analogicznej funkcji jak pokazano w // 2 przyjmującej referencje do r-wartości. Przecież przekazując przez wartość std::string("Andrzej") do konstruktora // 1 skoro mamy przekazywanie przez wartość a nie referencję to i tak dokonywana jest kopia tego obiektu. Konkretnie tworzona jest obiekt tymczasowy i to co jest w tym obiekcie jest przenoszone do firstname i lastname. Gdyby użyć referencji do r-wartości to nie byłoby tworzonego żadnego obiektu tymczasowego.

Drugie moje pytanie dotyczy czy dobrze rozumiem dlaczego w kodzie załączonym do tego pytania mamy na wyjściu , , 12. Według mnie wyjście jest takie ponieważ w przypadku std::string zostały przeniesione zasoby powiązane z obiektem natomiast age nie otrzymał przypadkowej wartości dlatego że w tym przypadku age nie jest obiektem zarządzanym. Semantyka przenoszenia ma sens jedynie w odniesieniu do zasobów alokowanych dynamicznie przez new i delete lub w podobny sposób, zarządzanych przez instancje klas. W przypadku zwykłych intów czy floatów nie mamy odpowiedzialności za zwolnienie obiektu. Przeniesienie polega na transferze odpowiedzialności za zwolnienie zasobu. Ddobrze to rozumiem?

Trzecie pytanie dotyczy tego czy referencje do r-wartości wewnętrznie też są implementowane jako wskaźniki tak jak zwykłe l-wartościowe referencje.

Dziękuję i pozdrawiam,
whiteman808

Kopiuj
~ ❯ cat main.cpp                                                                                                                                ✘ INT 19:30:01
#include <iostream>
#include <string>

class person {
public:
    person() : firstname{}, lastname{}, age{0} {}
    person(std::string f, std::string l, int a) // 1
        : firstname{std::move(f)}, lastname{std::move(l)}, age{a} {
        std::cout << f << ", " << l << ", " << a << '\n';
    }
    person(std::string&& f, std::string&& l, int a) // 2
        : firstname{std::move(f)}, lastname{std::move(l)}, age{a} {
        std::cout << f << ", " << l << ", " << a << '\n';
    }
    person(const person& p) { // konstruktor kopiujący
        firstname = p.firstname;
        lastname = p.lastname;
        age = p.age;
    }
private:
    std::string firstname;
    std::string lastname;
    int age;
};

int main() {
    person p{"Jan", "Kowalski", 12};
    return 0;
}
~ ❯ ./main                                                                                                                                            19:30:04
, , 12


DA
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 150
0

Zasadniczo std::move(f) oznacza, że nie będę później korzystał z tego co znajdzie się w f. Wprawdzie standard gwarantuje, że f będzie w jakimś poprawnym stanie, ale co do zasady nie gwarantuje w jakim.

Dlatego drukuje Ci to co widzisz.

whiteman808
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 201
0

Czytam https://stackoverflow.com/questions/3106110/what-is-move-semantics

Czy dobrze rozumiem, że nie ma potrzeby robienia person(std::string&& f, std::string&& l, int a) ponieważ od C++11 jak przekazujemy argument przez wartość to kompilator sam decyduje czy zainicjalizować przekazywany obiekt poprzez konstruktor kopiujący jak przekazujemy funkcji l-wartość, czy przez konstruktor przenoszący w przypadku r-wartości?

Przekazując r-wartość do // 1 po prostu przenosimy r-wartość std::string("costam") do zmiennej typu std::string tworzonej w trakcie wywoływania funkcji?

SL
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 1022
0

Nie rozumiem natomiast tego czemu nie tworzymy analogicznej funkcji jak pokazano w // 2 przyjmującej referencje do r-wartości.

Można i jest to najlepsze rozwiązanie jeśli chodzi o wydajność. Czemu się tak nie robi? Bo dużo pisania i utrzymania. Generalnie przenoszenie jest zazwyczaj bardzo tanie (przekopiowanie adresu pamięci, adres jest krótki), więc przy dobrym inlinowaniu i optymalizacji ten narzut może być po prostu zerowy. Rust w porównaniu do C++ właśnie poszedł w tą stronę i semantyka przenoszenia wartości jest u nich bardzo prosta i zakłada, że kompilator jest mądry

W większości przypadków dobrze zaimplementowany konstruktor 1, 2 będą tak samo wydajne. 1 wykonuje więcej operacji, ale zakładamy, że kompilator usunie te niepotrzebne operacje

Nawiasem dodam, że dużo kodu w C++ jest pisana z myślą o optymalizatorze. Często kod kompilowany bez optymalizacji jest bardzo wolny, bo te wszystkie warstwy zero cost abstraction nie są zero cost

Według mnie wyjście jest takie ponieważ w przypadku std::string zostały przeniesione zasoby powiązane z obiektem natomiast age

To co robisz to UB. Jak obiekt zostanie przeniesiony to nie możesz z nim NIC robić poza ponowną inicializacją. Obiekt w takim stanie to takie zombie, które czeka aż się rozłoży. To duży problem, którego rozwiązaniem są destructive moves (jak w Ruscie), gdzie kompilator po prostu zabrania ci tykać obiekt po jego przeniesieniu

Co taki obiekt w stanie zombie ma robić? Musi się dać usunąć przez destruktor. Dlatego widzisz puste stringi w outpucie, bo najlogiczniejszą opcją jest po prostu wyczyszczenie stringa dzięki czemu:

  • przeniesiony wskaźnik na tablicę znaków jest osiągalny tylko przez std::string utworzony przez przeniesienie
  • destruktor z przenoszonego stringa wykona się prawidłowo tj. zrobi nic

Trzecie pytanie dotyczy tego czy referencje do r-wartości wewnętrznie też są implementowane jako wskaźniki tak jak zwykłe l-wartościowe referencje.

&& referencje to pod spodem to samo co zwykłe. Możesz o nich myśleć jak o zwykłych referencjach tylko z dodatkowym smakeim przez co w niektórych kontekstach kompilator wybiera inne funkcje i mechanizmy

Dlaczego tak to wygląda? Legacy:

  • C nie ma przenoszenia, wszystko jest zawsze kopiowane. Przenoszenie trzeba robić ręcznie
  • C++ zaczął wprowadzać obiekty z konstruktorem/destruktorem. Taki model programowania jest przyjemny, ale nie ma operacji przenoszenia, więc wszystko trzeba przenosić ręcznie albo używać wskaźników do typów co wprowadza dodatkowe alokacje i jest nieprzyjemne
  • C++11 wprowadził &&, bo to najlepszy sposób na dodanie tego ficzera bez psucia kodu (nie chcemy zmieniać wszystko jest zawsze kopiowane). Do tego r-values (np. temporary) są automatycznie rzutowane do &&, więc istniejący kod został automatycznie zoptymalizowany, bo wystarczyło zmienić klasę (np. dodać string (string&&)) a nie użytkowników

Taki Rust poszedł w inną stronę:

  • nie ma domyślnego kopiowania, defaultem jest move. Domyślne kopiowanie jest możliwe tylko dla małych obiektów, gdzie szybkość move == copy
  • "drogie" kopiowanie to po prostu clone + move, eleganckie.
  • nie da się napisać konstruktora przenoszącego, kompilator generuje go sam i nie możesz nic z tym zrobić (i dobrze)
  • jak chcesz dodać logikę klonowania to robisz to odpowiednim traitem co sprawia, że jedna funkcja zapewnia ci pełne wsparcie dla kopiowania i przenoszenia
  • destructive move (to co wspomniałem) chroni cię przed wszystkimi bugami i ułatwia pisanie kodu

Jak widać bagaż z C nie pozwala na zaimplementowanie tego mechanizmu dobrze. C++ rozwijał się ewolucyjnie, więc cały bagać poprzednich pokoleń jest konieczny. Rust mógł podejść do tego z czystą kartą ucząc się na błędach poprzedników

several
  • Rejestracja: dni
  • Ostatnio: dni
0

Nie rozumiem natomiast tego czemu nie tworzymy analogicznej funkcji jak pokazano w // 2 przyjmującej referencje do r-wartości.

Bo to bardzo rzadko ma jakieś zalety w stostunku do przekazywania przez wartość. Jak sam zauważyłeś, wydajność pozostaje ta sama, a wewnątrz samej funkcji łatwiej mentalnie operować na l-value niż na r-value. Także tu działa takie, skoro nie widać różnicy to po co przepłacać? Jest też takie coś jak copy elision do argumentu, nie jest gwarantowane i może się pojawić tak przy wartości jak i r-value w argumencie, ale przez to że wartość jest generalnie prostsza w obsłudze zakładałbym większe szanse na jej wystąpienie właśnie przy wartości. Ale to już jest moja spekulacja, nie robiłem żadnych wyrafinowanych testów w tym temacie.

Zazwyczaj jak się widzi r-value w parametrze to jest to oznaka braków wiedzy programisty i problemów w kodzie. Także plus dla Ciebie za zadawanie pytań. Najczęstszym i uzasadnionym użyciem podwójnego ampersanda && a argumencie to gdy piszemy szablony, tylko, że w tym przypadku taki zapis nie oznacza r-value w każdym przypadku.

Jak obiekt zostanie przeniesiony to nie możesz z nim NIC robić poza ponowną inicializacją.

Możesz go jeszcze manualnie zniszczyć przez zawołanie desktruktora, jeśli !std::is_trivially_destructible_v<T>;. Co czasem musisz zrobić jak implementujesz typu w stylu optional, expected itp.

Offtop

bo te wszystkie warstwy zero cost abstraction nie są zero cost

Nie wszystkie. Twierdzenie że wszystkie lub żadne zero cost abstraction są/nie są zero cost to taka sama propaganda wynikająca z jakiś subiektywny opinii ludzi na temat technologii. W rzeczywistości istnieją w C++ zero cost abstraction które są faktycznie zero cost i twierdzenie, że jest inaczej to wprowadzanie w błąd.

Taki Rust poszedł w inną stronę:

Jeśli spojrzysz na tytuł i dział, to powinno być jasne, że taki kontekst powinien być oznaczony jako offtop.

SL
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 1022
1
several napisał(a):

Możesz go jeszcze manualnie zniszczyć przez zawołanie desktruktora, jeśli !std::is_trivially_destructible_v<T>;. Co czasem musisz zrobić jak implementujesz typu w stylu optional, expected itp.

Jasne, nie chciałem za dużo mieszać

W rzeczywistości istnieją w C++ zero cost abstraction które są faktycznie zero cost i twierdzenie, że jest inaczej to wprowadzanie w błąd.

Zgadzam się. Swoją wypowiedzia chciałem tylko wskazać, że same konstrukcje to nie wszystko i trzeba znać kompilator i jego możliwości, żeby pisać kod, który jest jednocześnie szybki i łatwy w rozwoju

Jeśli spojrzysz na tytuł i dział, to powinno być jasne, że taki kontekst powinien być oznaczony jako offtop.

IMO rys historyczny i możliwa alternatywa pomagają w zrozumieniu tematu. Ja sam nie byłbym w stanie zrozumieć geniuszu stojącego za r-value references w inny sposób niż porównując jak kolejne kroki były wprowadzanie w kolejnych standardach na zasadzie ewolucji podtrzymujących kompatybilność wsteczną

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.