PROE wykład 9 C++11, rzutowanie, optymalizacja dr inż. Jacek Naruniec
Rzutowanie Różne typy rzutowania są szczególnie istotne przy dziedziczeniu. Załóżmy sobie prostą hierarchię klas: A B C
Rzutowanie Najprostsze rzutowanie w kierunku klasy bazowej: W tym przypadku sprawa jest bardzo prosta.
Rzutowanie Co w przypadku rzutowania w drugą stronę? Możemy popełnić błąd, bo możemy nie wiedzieć jaką klasę pochodną reprezentuje dany wskaźnik: Kompilacja przebiegnie poprawnie, błędy wystąpią w momentach odwołania się do błędnie rzutowanego obiektu.
Rzutowanie Okazuje się, że rzutowania możemy dokonać na wiele sposobów, m.in.: static_cast standardowe rzutowanie kiedy wiemy dokładnie co robimy, np. w przypadku prostej konwersji typów, przechodzenie z klasy pochodnej do bazowej itp. dynamic_cast rzutowanie dynamiczne, sprawdzające poprawność rzutowania. Wykaże błąd (NULL) w przypadku błędu. reinterpret_cast głównie służy do zmiany wskaźników, w szczególności przy przekazywaniu argumentów. const_cast rzutowanie, m.in. zmiennych const na typy bez const.
Rzutowanie static_cast W drugim przypadku nie jest wskazane!
Rzutowanie dynamic_cast
Rzutowanie reinterpret_cast Tutaj interpretuje wskaźnik jako inta:
Assert Kontrola poprawności aplikacji. Wykonuje się jedynie w trybie Debug (czyli deweloperskim). Jeśli warunek w funkcji nie jest spełniony, to wyświetlana jest linijka w której wystąpił błąd. Jeśli warunek jest spełniony, to funkcja nie wykonuje żadnej akcji.
Assert W trybie release (z optymalizacją) funkcja assert jest ignorowana przez kompilator (niezależnie od spełnienia warunku, program wykonuje się dalej).
Typedef Często zdarza się, że programiści chcą, aby standardowe zmienne nazywały się według własnych potrzeb. Np. chcemy zamiast słowa float stosować słowo lrzeczywista a listasloniowa zamiast Lista<Slon>: Typedefy są bardzo często spotykane przy różnych bibliotekach programistycznych, m.in. w WinAPI.
C++11 (C++0x) Nowy standard języka C++ Wprowadzony w 2011 roku. Założenia (m.in.): Ma być zgodny z poprzednim standardem. Ma ułatwić programowanie i naukę programowania C++ (m.in. wprowadzenie wielu elementów istniejących w innych językach programowania) Ma preferować rozszerzenia nad zmianą bazy języka. W chwili obecnej obsługiwany przez znaczną większość kompilatorów.
Nowe typy danych long long, unsigned long long (64 bitowe). char16_t, char32_t 16 i 32 bitowe reprezentacje znaków (np. do przechowywania znaków w UTF16 i UTF32) surowy napis (bez interpretacji), po cudzysłowie muszą być nawiasy:
Tuple Zbiór zmiennych o określonej strukturze. Typ szablonowy, sami określamy typy parametrów. Referencję na konkretny element otrzymujemy poprzez funkcję get<indeks>(zmienna). Wymaga #include<tuple>.
Nowe metody inicjalizacji Obiekty inicjalizowane zwykle nie nawiasami () a {}. Inicjalizacja nie wymaga znaku =.
Nowe metody inicjalizacji Inicjalizacja obiektów (wywołanie konstruktora z parametrem):
Nowe metody inicjalizacji Wygodna inicjalizacja wartości domyślnych składowych klasy:
Nowe typy deklaracji auto automatyczny dobór typu zmiennej w zależności od inicjalizacji:
Nowe typy deklaracji decltype nadanie zmiennej typu odpowiadającego typowi podanej zmiennej
Nowe typy deklaracji Nowa składnia definiowania typu zwracanej zmiennej (trailing return) parametr zwracany definiowany jest na końcu funkcji: oznacza dokładnie to samo Niedopuszczalne! bo d nie jest jeszcze znane kompilatorowi Poprawne! d jest przed decltype
NULL nullptr stosowany zamiast NULL. Oznacza rzeczywiście wskaźnik nie wskazujący na żadne dane, a nie wartość (np. 0). Pomaga uniknąć niektórych błędnych zapisów. Nie zadziała, bo próbujemy przypisać wskaźnik do liczby Zadziała, bo próbujemy przypisać liczbę do liczby
NULL
Inteligentne wskaźniki (smart pointers) Wspomaga proces zarządzania pamięcią (tworzenia/usuwania zmiennych dynamicznych). Inteligentne wskaźniki zachowują się jak wskaźniki, ale posiadają kilka rozszerzeń. Jest kilka typów inteligentnych wskaźników, m.in. auto_ptr już przedawniony unique_ptr ulepszony auto_ptr shared_ptr wskaźnik z licznikiem wskaźników wskazujących na tę samą informację
Inteligentne wskaźniki Obiekty zaalokowane dynamicznie są automatycznie usuwane po usunięciu obiektu auto_ptr. auto_ptr jest klasą, która ma konstruktory, destruktor itp. Nie obsługuje tablic (delete, nie delete[]).
Inteligentne wskaźniki Przypisanie inteligentnego wskaźnika auto_ptr powoduje zmianę jego właściciela : Ten wskaźnik na nic nie wskazuje, bo został przejęty.
Inteligentne wskaźniki Unique_pointer Obsługuje tablice (delete[]). Błąd na etapie kompilacji a nie uruchomieniu. Może być przenoszony (o konstruktorach przenoszących na dalszych slajdach), czyli np. zwracany w miejscach, gdzie unique_ptr jest zmienną tymczasową. Nie może być zwyczajnie kopiowany.
Inteligentne wskaźniki shared_ptr zlicza liczbą elementów wskazujących na to samo miejsce. Obiekt usuwa się po wyzerowaniu licznika.
Enumerate zakresowy Zmienne wyliczeniowe mogą odnosić się do konkretnego zakresu:
Foreach Pętla, która dla każdego elementu pewnego kontenera wykonuje osobno określone zadanie. For each w tym wypadku kopiuje każdy element z wektora ceny i wpisuje go za każdym razem do zmiennej cena (pętla wykona tyle iteracji tyle razy ile mamy elementów w wektorze).
Foreach Można także pobierać nie kopie elementów, a referencje (wykorzystamy dla powtórzenia auto): W tym momencie operujemy bezpośrednio na elementach wektora, nie na kopiach. W tym momencie zmiana obiektu wewnątrz pętli oznacza zmianę w wektorze.
lvalues, rvalues Aby wprowadzić efektywną metodę przenoszenia elementów należy zapoznać się z dwiema rodzajami elementów lvalue i rvalue. Nieformalna definicja 1: lvalue to taki element, który może występować zarówno po lewej jak i po prawej stronie równania (a1=a2 i a2=a1 to znaczy, że a1 i a2 to lvalue) Rvalue to taki element, który może występować tylko po prawej stronie równania (a1=2 ale nie 2=a1)
lvalues, rvalues Nieformalna definicja 2: Z lvalue możemy pobrać adres (int a1; b = &a1, więc a1 jest lvalue) Z rvalue nie możemy pobrać adresu np. z a+b, bo jest to obiekt tymczasowy np. z liczby (nie może być a = &2)
lvalue, rvalue Do lvalue odnosi się taka referencja jaką znamy (&), natomiast do rvalue - &&: do 2 i do wyniku i+i nie pobierzemy adresu
Konstruktor przenoszący Po co nam l i rvalue? Przyjmijmy prosty obiekt: dynamicznie alokowana tablica znaków
Konstruktor przenoszący Dodajmy konstruktor kopiujący i wykorzystajmy klasę:
Konstruktor przenoszący Jak widać przy operatorze + następuje stworzenie obiektu tymczasowego a potem jego kopiowanie (i całej tablicy). A gdyby zamiast kopiowania całego obiektu skopiować jedynie zmienne niewskaźnikowe, natomiast wskaźniki jedynie przekazać nowemu obiektowi? Tymczasowy obiekt i tak się skasuje, wskaźnik mu już niepotrzebny Operator + zwraca obiekt typu rvalue, więc stwórzmy dla niego specjalny konstruktor kopiujący: 1. Skopiowanie wskaźnika na tablicę z obiektu tymczasowego do aktualnego. 2. Ustawienie wskaźnika obiektu tymczasowego na nullptr dzięki temu destruktor obiektu tymczasowego go nie skasuje!
Konstruktor przenoszący Efekt? Nie kopiujemy tablicy a jedynie wskaźniki na tablice! Daje to ogromny wzrost wydajności. Nowy obiekt kradnie tablicę. Poprzednio: rvalue Teraz:
std::move Funkcja move określa, że zmienna może zostać przeniesiona operatorem przenoszącym (która domyślnie byłaby zwyczajnie skopiowana) napis pozostał niezmieniony wskaźnik na ciąg został zmieniony, nie możemy już z niego korzystać
C++11 Inne istotne elementy standardu: Wątki (wreszcie!) Default (domyślne funkcje, konstruktory, funkcja = delete zakazanie używania metody (np. operatora przypisania) Delegaci wykonanie w konstruktorze treści innego konstruktora tak, aby nie powielać kodu Wyrażenia regularne, Inne
Optymalizacja kodu C++ Metoda optymalizacji zależy od jej celu. Zwykle jest to poprawa wydajności (szybkości) aplikacji zmniejszenie ilości zajmowanej pamięci poprawa przejrzystości, modułowości kodu Na wykładzie zajmiemy się poprawą wydajności aplikacji.
Tryby debug/release Pierwszy krok tryb debug służy jedynie procesom deweloperskim. Innymi słowy: Kod debug jest znacznie wolniejszy od release, Program stworzony w trybie debug w wielu przypadkach nie uruchomi się na innych komputerach (np. przy VS wymaga obecności bibliotek VS na uruchamianym komputerze)
Tryby debug/release W trybie release kod jest optymalizowany, co uniemożliwia w większości przypadków podglądanie zmiennych. Poszukiwanie błędów można sobie umożliwić poprzez komunikaty kontrolne albo wypisywanie informacji do pliku. Coś co uruchamia się poprawnie w debug nie musi uruchamiać się poprawnie w release (po zoptymalizowaniu mogą ujawnić się błędy implementacji). Ogólnie każdy program finalnie powinniśmy uruchamiać w trybie release.
Opcje preprocesora Często mamy możliwość wybrania odpowiednich opcji kompilacji w zależności od zapotrzebowania/sprzętu: preferencja małego lub szybkiego kodu, Wykorzystanie operacji wektorowych procesora (np. SSE), Optymalizacja pod względem funkcji inline Opcje te zwykle ustawiane są w opcjach projektu (W VS zakładki C++->Optimization i C++->Code generation)
Funkcje inline Ciało funkcji oznaczonych jako inline są przez kompilator wstawiane w miejsce wywołania. Kompilator nie musi respektować naszego życzenia Zwykle w procesie optymalizacji kompilator sam najlepiej określa które funkcje powinny być inline.
Podział zadań na wiele wątków We współczesnych komputerach mamy zwykle więcej niż jeden procesor. Możemy z nich korzystać wywołując kilka niezależnych programów: Procesor 1 Procesor 2 Procesor 3 Edytor Przeglądarka Film Procesor 4
Podział zadań na wiele wątków Jeśli na jednym procesorze wykonywanych jest więcej niż jeden program (proces), to wtedy system dysponuje po kawałku czasu procesora dla każdego programu. Możemy także wykonywać nasz jeden program na kilku procesorach Przykład - mamy aplikację wyszukującą w danym ciągu znaków określoną sekwencję (w praktyce miałoby to sens gdyby ciąg był bardzo długi): ABCDFADCACBCBABABCBDBACBABABAB Dzielimy tekst na 4 podzbiory i każemy każdemu procesorowi zająć się jednym fragmentem: ABCDFADCACBCBABABCBDBACBABABAB Procesor 1 Procesor 3 Procesor 2 Procesor 4
Podział zadań na wiele wątków Na koniec uwspólniamy wyniki wyszukiwania Teoretycznie przetwarzanie powinno być 4x szybsze (chociaż w praktyce wyjdzie mniej) Do tworzenia i zarządzania takimi osobnymi podprogramami (a dokładniej wątkami) są już w każdym środowisku gotowe narzędzia. Można także przeprowadzać obliczenia na całych klastrach komputerów. C++11 wprowadziło klasę thread do zarządzania wątkami. Można wykorzystać narzędzia działające z automatu (jak np. OpenMP)
Podział na wiele wątków wersja ekstremalna Wykorzystanie kart graficznych ostatnio bardzo popularny sposób zwiększenia wydajności, w szczególności w: Grach. Przetwarzaniu obrazu. Badaniach naukowych. Dlaczego jest to dobre? [źródło: NVIDIA CUDA C Programming Guide]
Podział na wiele wątków wersja ekstremalna Pytanie to dlaczego nie używamy tego do wszystkiego? [źródło: NVIDIA CUDA C Programming Guide]
Podział na wiele wątków wersja ekstremalna GPU (Graphical Processing Unit) jest zorientowane na dużą ilość przetwarzania przy minimalnej ilości operacji sekwencyjnych. Dostęp do pamięci musi odbywać się w sposób zorganizowany. [źródło: NVIDIA CUDA C Programming Guide]
Programowanie GPU Jest wiele technik pozwalających na programowanie GPU, np: shadery w potoku generowania obrazu, CUDA (Nvidia), OpenCl (teoretycznie ATI i Nvidia i dowolne inne urządzenia, Open Computing Language) C++ AMP Na chwilę obecną najpopularniejsza i najlepiej rozwinięta jest CUDA (Compute Unified Device Architecture)
Profiler Profilery są niesamowicie użyteczne przy wykrywaniu wąskich gardeł programów. Zwykle zatrzymują program co pewien okres czasu (np. co 50 mikrosekund) i sprawdzają w której funkcji aktualnie znajduje się program. Otrzymane wartości są sumowane. W Visual Studio wybieramy Analyze->Launch Performance Wizard (opcja dostępna w wybranych wersjach VS).
Profiler Ewidentnie widać, że wąskie gardło jest w destruktorze!
Profiler Klikamy dalej na funkcję zwrocwskaznikwezla:
Profiler Widać, że błędem jest przechodzenie od ostatniego węzła, bo wtedy wiele razy uruchamia się wspomniana funkcja! Zmieńmy nasz destruktor:
Profiler Program wykonał się 2x szybciej!
Profiler Pójdźmy dalej poprawmy push_back tak, aby nie trzeba było za każdym razem przechodzić przez wszystkie elementy. Jak? przecież można trzymać też wskaźnik na ostatni element! Nasza lista byłaby efektywna przy dodawaniu na początek listy.
Profiler Przyspieszyliśmy nasz program do niecałej 1.5 sekundy, tj. ok. 5 razy! Można dalej? To już zależy od tego czego potrzebujemy modułowość/prostota/szybkość.
Inne metody optymalizacji Przy obliczeniach/algorytmach itp. pierwszą rzeczą zawsze jest poszukiwanie zysku szybkości w samym algorytmie. Czy wszystkie kroki trzeba wykonywać? Czy można gdzieś oszczędzić? Czy można przeorganizować kod aby był efektywniejszy? Można próbować pisania kodu asemblerowego zwykle kończy się tym, że kompilator potrafi zrobić to lepiej od nas. Kompilator nie będzie myślał za nas zoptymalizuje tylko kod który mu dostarczyliśmy.
Ankiety! studia.elka.pw.edu.pl