Ostatnia aktualizacja: 12.08.2018

Wraz z metodami możemy również przeciążać operatory. Załóżmy na przykład, że mamy następującą klasę Counter:

Licznik klas ( public int Wartość ( get; set; ) )

Ta klasa reprezentuje pewien licznik, którego wartość jest przechowywana we właściwości Value.

I załóżmy, że mamy dwa obiekty klasy Counter - dwa liczniki, które chcemy porównać lub dodać na podstawie ich właściwości Value przy użyciu standardowych operacji porównywania i dodawania:

Licznik c1 = nowy Licznik (Wartość = 23 ); Licznik c2 = nowy Licznik (Wartość = 45 ); wynik logiczny = c1 > c2; Licznik c3 = c1 + c2;

Ale w tej chwili ani operacja porównania, ani operacja dodawania nie są dostępne dla obiektów licznika. Te operacje mogą być używane na wielu typach pierwotnych. Na przykład domyślnie możemy dodawać wartości liczbowe, ale kompilator nie wie, jak dodawać obiekty typu złożonego - klasy i struktury. A do tego musimy przeciążyć operatorów, których potrzebujemy.

Przeciążanie operatorów polega na zdefiniowaniu specjalnej metody w klasie, dla której obiektów chcemy zdefiniować operator:

publiczny statyczny operator typu return_type (parametry) ( )

Ta metoda musi mieć publiczne modyfikatory statyczne, ponieważ przeciążony operator będzie używany dla wszystkich obiektów tej klasy. Następnie pojawia się nazwa typu zwracanego. Zwracany typ reprezentuje typ, którego obiekty chcemy pobrać. Na przykład, dodając dwa obiekty Counter, spodziewamy się otrzymać nowy obiekt Counter. W wyniku porównania tych dwóch chcemy uzyskać obiekt typu bool, który wskazuje, czy wyrażenie warunkowe jest prawdziwe czy fałszywe. Ale w zależności od zadania typy zwracane mogą być dowolne.

Następnie zamiast nazwy metody pojawia się słowo kluczowe operator i sam operator. A następnie parametry są wymienione w nawiasach. Operatory binarne przyjmują dwa parametry, operatory jednoargumentowe przyjmują jeden parametr. W każdym razie jeden z parametrów musi reprezentować typ — klasę lub strukturę — ​​w której zdefiniowany jest operator.

Na przykład przeciążmy kilka operatorów dla klasy Counter:

Class Counter ( public int Value ( get; set; ) publiczny statyczny Operator licznika +(Counter c1, Counter c2) ( zwraca nowy Counter ( Value = c1.Value + c2.Value ); ) publiczny statyczny operator bool >(Counter c1, Licznik c2) ( return c1.Value > c2.Value; ) publiczny statyczny operator bool<(Counter c1, Counter c2) { return c1.Value < c2.Value; } }

Ponieważ wszystkie przeciążone operatory są binarne — to znaczy są wykonywane na dwóch obiektach, dla każdego przeciążenia istnieją dwa parametry.

Ponieważ w przypadku operacji dodawania chcemy dodać dwa obiekty klasy Licznik, operator akceptuje dwa obiekty tej klasy. A ponieważ chcemy otrzymać nowy obiekt Counter w wyniku dodawania, ta klasa jest również używana jako typ zwracany. Wszystkie działania tego operatora sprowadzają się do stworzenia nowego obiektu, którego właściwość Value łączy wartości właściwości Value obu parametrów:

Publiczny statyczny Operator Licznika +(Licznik c1, Licznik c2) ( zwróć nowy Licznik ( Value = c1.Value + c2.Value ); )

Zmieniono również definicje dwóch operatorów porównania. Jeśli przedefiniujemy jeden z tych operatorów porównania, musimy również przedefiniować drugi z tych operatorów porównania. Same operatory porównania porównują wartości właściwości Value i, w zależności od wyniku porównania, zwracają prawdę lub fałsz.

Teraz w programie używamy przeciążonych operatorów:

Static void Main(string args) ( Counter c1 = new Counter ( Value = 23 ); Counter c2 = new Counter ( Value = 45 ); bool result = c1 > c2; Console.WriteLine(result); // false Counter c3 = c1 + c2;Console.WriteLine(c3.Value); // 23 + 45 = 68 Console.ReadKey(); )

Warto zauważyć, że skoro definicja operatora jest zasadniczo metodą, możemy również tę metodę przeciążać, czyli stworzyć dla niej inną wersję. Na przykład dodajmy kolejny operator do klasy Counter:

Publiczny statyczny operator int +(Counter c1, int val) ( return c1.Value + val; )

Ta metoda dodaje wartość właściwości Value i pewną liczbę, zwracając ich sumę. A także możemy zastosować ten operator:

Licznik c1 = nowy Licznik (Wartość = 23 ); int d = c1 + 27; // 50 Konsola.WriteLine(d);

Należy pamiętać, że przeciążenie nie powinno zmieniać tych obiektów, które są przekazywane operatorowi za pomocą parametrów. Na przykład możemy zdefiniować operator inkrementacji dla klasy Counter:

Publiczny statyczny Operator licznika ++(Counter c1) ( c1.Value += 10; return c1; )

Ponieważ operator jest jednoargumentowy, przyjmuje tylko jeden parametr - obiekt klasy, w której ten operator jest zdefiniowany. Jest to jednak błędna definicja przyrostu, ponieważ operator nie może zmieniać wartości jego parametrów.

A bardziej poprawne przeciążenie operatora inkrementacji wyglądałoby tak:

Publiczny statyczny operator Licznika ++(Counter c1) ( zwróć nowy Licznik ( Value = c1.Value + 10 ); )

Oznacza to, że zwracany jest nowy obiekt zawierający zwiększoną wartość we właściwości Value.

Jednocześnie nie musimy definiować oddzielnych operatorów dla przyrostu prefiksu i przyrostka (a także dekrementacji), ponieważ w obu przypadkach zadziała jedna implementacja.

Na przykład używamy operacji przyrostu prefiksu:

Licznik licznika = nowy Licznik() ( Wartość = 10 ); Console.WriteLine($"(licznik.Wartość)"); // 10 Console.WriteLine($"((++counter).Value)"); // 20 Console.WriteLine($"(counter.Value)"); // 20

Wyjście konsoli:

Teraz używamy przyrostu przyrostka:

Licznik licznika = nowy Licznik() ( Wartość = 10 ); Console.WriteLine($"(licznik.Wartość)"); // 10 Console.WriteLine($"((licznik++).Value)"); // 10 Console.WriteLine($"(counter.Value)"); // 20

Wyjście konsoli:

Warto również zauważyć, że możemy nadpisać operatory prawdziwe i fałszywe. Na przykład zdefiniujmy je w klasie Counter:

Class Counter ( public int Value ( get; set; ) publiczny statyczny operator bool true(Counter c1) ( return c1.Value != 0; ) publiczny statyczny operator bool false(Counter c1) ( return c1.Value == 0; ) // reszta zawartości klasy )

Te operatory są przeciążone, gdy chcemy użyć obiektu typu jako warunku. Na przykład:

Licznik licznika = nowy Licznik() ( Wartość = 0 ); if (licznik) Console.WriteLine(true); else Console.WriteLine(false);

Podczas przeciążania operatorów należy pamiętać, że nie wszyscy operatorzy mogą być przeciążeni. W szczególności możemy przeciążyć następujące operatory:

    operatory jednoargumentowe +, -, !, ~, ++, --

    operatory binarne +, -, *, /, %

    operacje porównania ==, !=,<, >, <=, >=

    operatory logiczne &&, ||

    operatory przypisania +=, -=, *=, /=, %=

Istnieje wiele operatorów, których nie można przeciążyć, takich jak operator równości = lub operator trójskładnikowy ?: i wiele innych.

Pełną listę przeciążonych operatorów można znaleźć w dokumentacji msdn

Przy przeciążaniu operatorów pamiętajmy również, że nie możemy zmienić pierwszeństwa operatora ani jego asocjatywności, nie możemy utworzyć nowego operatora, ani zmienić logiki operatorów w typach, co jest domyślne w .NET.

Dobry dzień!

Chęć napisania tego artykułu pojawiła się po przeczytaniu posta, ponieważ wiele ważnych tematów nie zostało w nim ujawnionych.

Najważniejszą rzeczą do zapamiętania jest to, że przeciążenie operatora jest po prostu wygodniejszym sposobem wywoływania funkcji, więc nie daj się ponieść przeciążeniu operatora. Powinien być używany tylko wtedy, gdy ułatwia pisanie kodu. Ale nie na tyle, żeby utrudniało to czytanie. W końcu, jak wiadomo, kod jest czytany znacznie częściej niż jest pisany. I nie zapominaj, że nigdy nie będziesz mógł przeciążać operatorów w tandemie z wbudowanymi typami, tylko niestandardowe typy/klasy mogą być przeciążone.

Składnia przeciążenia

Składnia przeciążania operatorów jest bardzo podobna do definiowania funkcji o nazwie [e-mail chroniony], gdzie @ jest identyfikatorem operatora (np. +, -,<<, >>). Rozważać najprostszy przykład:
class Integer ( private: int wartość; public: Integer(int i): value(i) () const Integer operator+(const Integer& rv) const ( return (value + rv.value); ) );
W takim przypadku operator jest umieszczony w ramce jako element członkowski klasy, argument określa wartość znajdującą się po prawej stronie operatora. Ogólnie istnieją dwa główne sposoby przeciążania operatorów: funkcje globalne, które są przyjazne dla klasy, lub funkcje wbudowane w samej klasie. Którą metodę, dla którego operatora jest lepszy, rozważymy na końcu tematu.

W większości przypadków operatory (oprócz warunków warunkowych) zwracają obiekt lub referencję do typu, do którego odnoszą się jego argumenty (jeśli typy są różne, to Ty decydujesz, jak interpretować wynik oceny operatora).

Przeciążanie operatorów jednoargumentowych

Rozważ przykłady przeciążania operatorów jednoargumentowych dla zdefiniowanej powyżej klasy Integer. Jednocześnie zdefiniujemy je jako funkcje zaprzyjaźnione i rozważymy operatory dekrementacji i inkrementacji:
class Integer ( private: int value; public: Integer(int i): value(i) () //unary + zaprzyjaźniony const Integer& operator+(const Integer& i); //unary - zaprzyjaźniony const Integer operator-(const Integer& i) //przyrost przyjaciela const Integer& operator++(Integer& i); //przyrostek przyrost przyjaciela const Integer operator++(Integer& i, int); //przyrostek przyrost przyjaciela const Integer& operator--(Integer& i); //przyrostek przyrost przyjaciela const Operator całkowity--(Integer& i, int); ); // jednoargumentowy plus nic nie robi. const Integer& operator+(const Integer& i) ( zwraca i.wartość; ) const Integer operator-(const Integer& i) ( zwraca Integer(-i.value); ) //wersja prefiksu zwraca wartość po inkrecji const Integer& operator++(Integer& i) ( i.value++; return i; ) //wersja postfix zwraca wartość przed inkrementacją const Operator Integer++(Integer& i, int) ( Integer oldValue(i.value); i.value++; return oldValue; ) //wersja przedrostkowa zwraca wartość po dekrementacji const Operator Integer&--(Integer& i) ( i.value--; return i; ) //wersja z przyrostkiem zwraca wartość przed dekrementacją const Operator Integer--(Integer& i, int) ( Integer oldValue(i.value ); i .value--; return oldValue; )
Teraz wiesz, jak kompilator rozróżnia wersje dekrementacji i inkrementacji z prefiksem i postfiksem. W przypadku, gdy widzi wyrażenie ++i, to wywoływana jest funkcja operatora++(a). Jeśli widzi i++, to wywoływany jest operator++(a, int). Oznacza to, że wywoływana jest funkcja przeciążonego operatora++ i do tego służy dummy int parametr w wersji postfiksowej.

Operatory binarne

Rozważ składnię przeciążania operatorów binarnych. Przeciążmy jeden operator, który zwraca l-wartość, jeden operator warunkowy oraz jedno stwierdzenie tworzące nową wartość (definiujemy je globalnie):
class Integer ( private: int wartość; public: Integer(int i): wartość(i) () przyjaciel const Integer operator+(const Integer& left, const Integer& right); friend Integer& operator+=(Integer& left, const Integer& right); friend operator bool==(const Integer& left, const Integer& right); ); const Integer operator+(const Integer& left, const Integer& right) ( return Integer(left.value + right.value); ) Integer& operator+=(Integer& left, const Integer& right) ( left.value += right.value; return left; ) operator bool==(const Integer& left, const Integer& right) ( return left.value == right.value; )
We wszystkich tych przykładach operatory są przeciążone dla tego samego typu, jednak nie jest to wymagane. Możliwe jest na przykład przeciążenie dodania naszego typu Integer i zdefiniowanego na jego podobieństwo Float.

Argumenty i zwracane wartości

Jak widać, przykłady używają różne drogi przekazywanie argumentów do funkcji i zwracanie wartości operatorów.
  • Jeśli argument nie jest modyfikowany przez operator, w przypadku np. jednoargumentowego plusa, musi być przekazany jako odwołanie do stałej. Ogólnie dotyczy to prawie wszystkich. operatory arytmetyczne(dodawanie, odejmowanie, mnożenie...)
  • Typ zwracanej wartości zależy od charakteru operatora. Jeśli operator musi zwrócić nową wartość, to musi zostać utworzony nowy obiekt (jak w przypadku binarnego plusa). Jeśli chcesz zapobiec zmianie obiektu jako l-wartości, musisz zwrócić go jako stałą.
  • Operatory przypisania muszą zwracać odwołanie do zmienionego elementu. Ponadto, jeśli chcesz użyć operatora przypisania w konstrukcjach takich jak (x=y).f(), gdzie funkcja f() jest wywoływana dla zmiennej x, po przypisaniu do niej y, nie zwracaj referencji do stałej, po prostu zwróć odwołanie.
  • Operatory logiczne powinny w najgorszym przypadku zwracać int, aw najlepszym bool.

Optymalizacja wartości zwrotu

Przy tworzeniu nowych obiektów i zwracaniu ich z funkcji należy posługiwać się notacją jak na przykładzie opisanego powyżej operatora binarnego plus.
return Integer(lewa.wartość + prawa.wartość);
Szczerze mówiąc nie wiem, jaka sytuacja jest istotna dla C++11, wszystkie poniższe argumenty są ważne dla C++98.
Na pierwszy rzut oka wygląda to jak składnia do tworzenia obiektu tymczasowego, co oznacza, że ​​nie ma różnicy między powyższym kodem a tym:
Integer temp(lewa.wartość + prawa.wartość); temp. powrotu;
Ale tak naprawdę w tym przypadku konstruktor zostanie wywołany w pierwszym wierszu, następnie zostanie wywołany konstruktor kopiujący, który skopiuje obiekt, a następnie, gdy stos zostanie rozwinięty, zostanie wywołany destruktor. Używając pierwszego wpisu, kompilator początkowo tworzy w pamięci obiekt, do którego ma zostać skopiowany, zapisując w ten sposób wywołanie konstruktora kopiującego i destruktora.

Operatorzy specjalni

W C++ istnieją operatory, które mają określoną składnię i metodę przeciążania. Na przykład operator indeksu . Jest on zawsze definiowany jako element klasy, a ponieważ zamierzone jest zachowanie indeksowanego obiektu jako tablicy, powinien zwrócić referencję.
operator przecinka
Operatory „specjalne” zawierają również operator przecinka. Jest wywoływana dla obiektów, które mają obok siebie przecinek (ale nie jest wywoływana na listach argumentów funkcji). Wymyślenie sensownego przykładu użycia tego operatora nie jest takie proste. Habrauser w komentarzach do poprzedniego artykułu o przeciążaniu.
Operator wyłuskiwania wskaźnika
Przeciążanie tych operatorów może być uzasadnione w przypadku klas inteligentnych wskaźników. Ten operator jest z konieczności zdefiniowany jako funkcja klasy i nałożone są na niego pewne ograniczenia: musi zwracać obiekt (lub referencję) lub wskaźnik, który umożliwia dostęp do obiektu.
operator przypisania
Operator przypisania jest koniecznie zdefiniowany jako funkcja klasy, ponieważ jest nierozerwalnie związany z obiektem na lewo od „=”. Globalne zdefiniowanie operatora przypisania umożliwiłoby przesłonięcie standardowego zachowania operatora „=”. Przykład:
class Integer ( private: int wartość; public: Integer(int i): value(i) () Integer& operator=(const Integer& right) ( // sprawdź samoprzypisanie if (this == &right) ( return *this; ) wartość = prawa.wartość; zwróć *to; ) );

Jak widać, na początku funkcji wykonywane jest sprawdzenie samoprzypisania. Ogólnie rzecz biorąc, w tym przypadku samoprzypisanie jest nieszkodliwe, ale sytuacja nie zawsze jest taka prosta. Na przykład, jeśli obiekt jest duży, możesz poświęcić dużo czasu na niepotrzebne kopiowanie lub pracę ze wskaźnikami.

Operatorzy bez przeciążania
Niektóre operatory w C++ nie są w ogóle przeciążone. Najwyraźniej odbywa się to ze względów bezpieczeństwa.
  • Operator wyboru elementu klasy „.”.
  • Wskaźnik do operatora wyłuskiwania elementu klasy ".*"
  • W C++ nie ma operatora potęgowania (jak w Fortran) „**”.
  • Zabronione jest definiowanie operatorów (możliwe są problemy z priorytetyzacją).
  • Nie można zmienić pierwszeństwa operatora
Jak już dowiedzieliśmy się, istnieją dwa sposoby operatorów - w postaci funkcji klasy oraz w postaci zaprzyjaźnionej funkcji globalnej.
Rob Murray w swojej książce C++ Strategies and Tactics określił następujące wskazówki dotyczące wyboru formy operatora:

Dlaczego? Po pierwsze, niektórzy operatorzy są początkowo ograniczeni. Ogólnie rzecz biorąc, jeśli semantycznie nie ma różnicy, jak zdefiniować operator, to lepiej ustawić go jako funkcję klasy, aby podkreślić połączenie, a dodatkowo funkcja będzie inline (inline). Ponadto czasami może być konieczne przedstawienie lewego operandu obiektem innej klasy. Prawdopodobnie najbardziej uderzającym przykładem jest redefinicja<< и >> dla strumieni I/O.

W rozdziale 15 przyjrzymy się dwóm rodzajom funkcji specjalnych: przeciążonym operatorom i konwersjom zdefiniowanym przez użytkownika. Umożliwiają używanie obiektów klas w wyrażeniach w taki sam intuicyjny sposób jak obiektów typów wbudowanych. W tym rozdziale najpierw przedstawimy ogólne koncepcje projektowe dla przeciążonych operatorów. Następnie wprowadzamy koncepcję przyjaciół klasy ze specjalnymi prawami dostępu i omawiamy, dlaczego są one używane, koncentrując się na implementacji niektórych przeciążonych operatorów: przypisania, indeksu dolnego, wywołania, strzałki dostępu do elementu klasy, inkrementacji i dekrementacji oraz wyspecjalizowanych operatorów dla klasy. operatorzy nowe i usuń. Inną kategorią funkcji specjalnych omówionych w tym rozdziale są funkcje konwersji składowych (konwertery), które są zbiorem standardowych konwersji dla typu klasy. Są one niejawnie stosowane przez kompilator, gdy obiekty klasy są używane jako rzeczywiste argumenty funkcji lub jako operandy wbudowanych lub przeciążonych operatorów. Rozdział kończy się szczegółowym podsumowaniem reguł rozwiązywania problemów z przeciążaniem funkcji, z uwzględnieniem przekazywania obiektów jako argumentów, funkcji składowych klas i przeciążonych operatorów.

15.1. Przeciążenie operatora

W poprzednich rozdziałach pokazaliśmy już, że przeciążanie operatorów umożliwia programiście wprowadzenie własnych wersji predefiniowanych operatorów (patrz Rozdział 4) dla operandów typu klasy. Na przykład klasa String w sekcji 3.15 ma wiele przeciążonych operatorów. Poniżej znajduje się jego definicja:

#włączać klasa Ciąg; istream& operator>>(istream &, const String &); strumień i operator<<(ostream &, const String &); class String { public: // набор перегруженных конструкторов // для автоматической инициализации String(const char* = 0); String(const String &); // деструктор: автоматическое уничтожение ~String(); // набор перегруженных операторов присваивания String& operator=(const String &); String& operator=(const char *); // перегруженный оператор взятия индекса char& operator(int); // набор перегруженных операторов равенства // str1 == str2; bool operator==(const char *); bool operator==(const String &); // функции доступа к членам int size() { return _size; }; char * c_str() { return _string; } private: int _size; char *_string; };

Klasa String ma trzy zestawy przeciążonych operatorów. Pierwszy to zestaw operatorów przypisania:

Najpierw pojawia się operator przypisania kopii. (Omówiono je szczegółowo w rozdziale 14.7.) Poniższa instrukcja wspiera przypisanie ciągu znaków C do obiektu typu String:

nazwa ciągu; name="Sherlock"; // użycie operatora operator=(znak *)

(Operatorzy przydziału inni niż przydziały kopii zostaną omówione w sekcji 15.3.)

Drugi zbiór ma tylko jednego operatora - biorąc indeks:

// przeciążony operator indeksu char& operator(int);

Pozwala programowi indeksować obiekty klasy String w taki sam sposób jak tablice obiektów typu wbudowanego:

If (nazwa != "S") cout<<"увы, что-то не так\n";

(Ten operator jest szczegółowo opisany w rozdziale 15.4.)

Trzeci zestaw definiuje przeciążone operatory równości dla obiektów klasy String. Program może sprawdzić, czy dwa takie obiekty są równe lub czy obiekt i C-string są równe:

// zestaw przeciążonych operatorów równości // str1 == str2; operator logiczny==(const char *); operator bool==(const String &);

Operatory przeciążone umożliwiają używanie obiektów typu klasy z operatorami zdefiniowanymi w rozdziale 4 i manipulowanie nimi tak intuicyjnie, jak obiektami typów wbudowanych. Na przykład, gdybyśmy chcieli zdefiniować operację łączenia dwóch obiektów String, moglibyśmy zaimplementować to jako funkcję składową concat(). Ale dlaczego concat(), a nie, powiedzmy, append()? Wybrana przez nas nazwa jest logiczna i łatwa do zapamiętania, ale użytkownik nadal może zapomnieć, jak nazwaliśmy funkcję. Nazwa jest często łatwiejsza do zapamiętania, jeśli zdefiniujesz przeciążony operator. Na przykład zamiast concat() wywołalibyśmy nowy operator operacji+=(). Taki operator jest używany w następujący sposób:

#include "String.h" int main() ( String nazwa1 "Sherlock"; String nazwa2 "Holmes"; nazwa1 += " "; nazwa1 += nazwa2; if (! (nazwa1 == "Sherlock Holmes")) cout< < "конкатенация не сработала\n"; }

Przeciążony operator jest deklarowany w treści klasy, tak jak zwykła funkcja składowa, z tą różnicą, że jego nazwa składa się ze słowa kluczowego operator, po którym następuje jeden z wielu predefiniowanych operatorów C++ (zobacz Tabela 15-1). W ten sposób można zadeklarować operator+=() w klasie String:

Class String ( public: // zestaw przeciążonych operatorów += String& operator+=(const String &); String& operator+=(const char *); // ... private: // ... );

i zdefiniuj to tak:

#włączać inline String& String::operator+=(const String &rhs) ( // Jeśli ciąg, do którego odwołuje się rhs, nie jest pusty if (rhs._string) ( String tmp(*this); // przydziel wystarczającą ilość pamięci, aby // przechować połączone of strings _size += rhs._size; delete _string; _string = new char[ _size + 1 ]; // najpierw skopiuj oryginalny ciąg do zaznaczonego obszaru // następnie dodaj na końcu ciąg, do którego odwołuje się rhs strcpy(_string, tmp ._string) ; strcpy(_string + tmp._size, rhs._string); ) return *this; ) inline String& String::operator+=(const char *s) ( // Jeśli wskaźnik s jest niezerowy if (s) ( String tmp(*this ); // przydziel obszar pamięci wystarczający // do przechowywania połączonych ciągów _size += strlen(s); usuń _string; _string = new char[ _size + 1 ]; // najpierw skopiuj ciąg źródłowy do przydzielony obszar // następnie dołącz na końcu ciągu C, do którego odwołuje się s strcpy(_string, tmp._string); strcpy(_string + tmp._size, s); ) return *this; )

15.1.1. Członkowie i osoby niebędące członkami klasy

Przyjrzyjmy się bliżej operatorom równości w naszej klasie String. Pierwszy operator pozwala ustawić równość dwóch obiektów, a drugi - obiekt i C-string:

#include "String.h" int main() ( String flower; // napisz coś do zmiennej flower if (flower == "lily") // popraw // ... else if ("tulip" == flower ) // błąd // ... )

Przy pierwszym użyciu operatora równości w main() wywoływany jest przeciążony operator==(const char *) klasy String. Jednak w drugiej instrukcji if kompilator zgłasza komunikat o błędzie. O co chodzi?

Przeciążony operator, który jest członkiem klasy, ma zastosowanie tylko wtedy, gdy lewy operand jest obiektem tej klasy. Ponieważ w drugim przypadku lewy operand nie należy do klasy String, kompilator próbuje znaleźć wbudowany operator, dla którego lewy operand może być C-stringiem, a prawy operand może być obiektem klasy String. Oczywiście nie istnieje, więc kompilator zgłasza błąd.

Ale możesz również utworzyć obiekt klasy String z C-stringu za pomocą konstruktora klasy. Dlaczego kompilator nie wykonuje tej konwersji niejawnie:

If (String("tulipan") == kwiat) //poprawnie: operator składowy jest wywoływany

Powodem jest jego nieefektywność. Przeciążone operatory nie wymagają, aby oba operandy były tego samego typu. Na przykład klasa Text definiuje następujące operatory równości:

Class Text ( public: Text(const char * = 0); Text(const Text &); // zestaw przeciążonych operatorów równości operator bool==(const char *) const; operator bool==(const String &) const; operator bool==(const Text &) const; // ... );

a wyrażenie w main() można przepisać w ten sposób:

If (Text("tulipan") == kwiat) // Text::operator==() jest wywoływany

Dlatego, aby znaleźć odpowiedni operator równości do porównania, kompilator będzie musiał przejrzeć wszystkie definicje klas w poszukiwaniu konstruktora, który może rzutować lewy operand na jakiś typ klasy. Następnie dla każdego z tych typów należy sprawdzić wszystkie powiązane z nim przeciążone operatory równości, aby sprawdzić, czy którykolwiek z nich może wykonać porównanie. A następnie kompilator musi zdecydować, która ze znalezionych kombinacji konstruktora i operatora równości (jeśli w ogóle) najlepiej pasuje do operandu po prawej stronie! Jeśli chcesz, aby kompilator wykonał wszystkie te czynności, czas tłumaczenia programów w języku C++ znacznie się wydłuży. Zamiast tego kompilator patrzy tylko na przeciążone operatory zdefiniowane jako składowe lewej klasy operandów (i jej klasy bazowe, jak pokażemy w rozdziale 19).

Dozwolone jest jednak zdefiniowanie przeciążonych operatorów, które nie są członkami klasy. Podczas parsowania wiersza w main(), który spowodował błąd kompilacji, takie instrukcje były brane pod uwagę. W ten sposób porównanie, w którym C-string znajduje się po lewej stronie, może być prawidłowe, zastępując operatory równości, które są członkami klasy String, operatorami równości zadeklarowanymi w zakresie przestrzeni nazw:

Operator logiczny==(const String &, const String &); operator bool==(const String &, const char *);

Należy zauważyć, że te globalne operatory przeciążone mają o jeden parametr więcej niż operatory członkowskie. Jeśli operator jest członkiem klasy, wskaźnik this jest niejawnie przekazywany jako pierwszy parametr. Oznacza to, że dla operatorów członkowskich wyrażenie

Kwiat == "lilia"

przepisany przez kompilator jako:

kwiat.operator==("lilia")

a do lewego operandu flower w definicji przeciążonego operatora elementu członkowskiego można się odwoływać. (Wskaźnik this został wprowadzony w sekcji 13.4) W przypadku globalnego operatora przeciążonego parametr reprezentujący lewy operand musi być wyraźnie określony.

Następnie wyrażenie

Kwiat == "lilia"

dzwoni do operatora

Operator bool==(const String &, const char *);

Nie jest jasne, który operator jest wywoływany w drugim przypadku użycia operatora równości:

"tulipan" == kwiat

Nie zdefiniowaliśmy takiego przeciążonego operatora:

Operator logiczny==(const char *, const String &);

Ale to jest opcjonalne. Gdy przeciążony operator jest funkcją w przestrzeni nazw, zarówno jej pierwszy, jak i drugi parametr (operandy lewy i prawy) są uważane za możliwe konwersje, tj. kompilator interpretuje drugie użycie operatora równości jako

Operator==(String("tulipan"), kwiat);

i wywołuje następujący przeciążony operator w celu wykonania porównania: operator bool==(const String &, const String &);

Ale dlaczego wprowadziliśmy drugi przeciążony operator: bool operator==(const String &, const char *);

Konwersję typu z C-string do klasy String można również zastosować do prawego operandu. Funkcja main() skompiluje się bez błędów, jeśli po prostu zdefiniujemy przeciążony operator w przestrzeni nazw, który przyjmuje dwa operandy String:

Operator logiczny==(const String &, const String &);

Czy podać tylko to stwierdzenie, czy jeszcze dwa:

Operator logiczny==(const char *, const String &); operator bool==(const String &, const char *);

zależy od tego, jak duży jest koszt konwersji z C-string na String w czasie wykonywania, to znaczy od „kosztu” dodatkowych wywołań konstruktora w programach, które używają naszej klasy String. Jeśli operator równości będzie często używany do porównywania ciągów i obiektów C, najlepiej podać wszystkie trzy opcje. (Wrócimy do kwestii wydajności w dziale o przyjaciołach.

Rzutowanie do typu klasy z konstruktorami omówimy bardziej szczegółowo w rozdziale 15.9; rozdział 15.10 dotyczy rozwiązywania przeciążeń funkcji przy użyciu opisanych transformacji, a rozdział 15.12 dotyczy rozwiązywania przeciążeń operatorów.)

Na jakiej więc podstawie podejmowana jest decyzja, czy uczynić operator członkiem klasy, czy członkiem przestrzeni nazw? W niektórych przypadkach programista po prostu nie ma wyboru:

  • jeśli przeciążony operator jest członkiem klasy, jest wywoływany tylko wtedy, gdy lewy operand jest członkiem tej klasy. Jeśli lewy operand jest innego typu, operator musi być członkiem przestrzeni nazw;
  • język wymaga, aby operatory przypisania ("="), indeksu dolnego (""), wywołania ("()") i dostępu do elementu członkowskiego ("->") były zdefiniowane jako członkowie klasy. W przeciwnym razie pojawia się komunikat o błędzie kompilacji:
// błąd: musi być członkiem klasy char& operator(String &, int ix);

(Operator przypisania jest omówiony bardziej szczegółowo w sekcji 15.3, pobieranie indeksu w sekcji 15.4, wywołanie w sekcji 15.5, a operator dostępu do elementu strzałki w sekcji 15.6.)

W przeciwnym razie decyzję podejmuje projektant klasy. Operatory symetryczne, takie jak operator równości, najlepiej zdefiniować w przestrzeni nazw, jeśli dowolny operand może być członkiem klasy (jak w String).

Przed zakończeniem tej podsekcji zdefiniujmy operatory równości dla klasy String w przestrzeni nazw:

Operator bool==(const String &str1, const String &str2) ( if (str1.size() != str2.size()) return false; return strcmp(str1.c_str(), str2.c_str()) ? false: true ; ) wbudowany operator bool==(const String &str, const char *s) ( return strcmp(str.c_str(), s) ? false: true ; )

15.1.2. Nazwy przeciążonych operatorów

Tylko predefiniowane operatory języka C++ mogą być przeciążane (patrz Tabela 15.1).

Tabela 15.1. Przeciążalne Operatory

+ - * / % ^ & | ~ ! , = < > <= >= ++ -- << >> == != && || += -= /= %= ^= &= |= *= <= >>= () -> ->* nowy nowy usuń usuń

Projektant klasy nie może deklarować operatora z przeciążoną inną nazwą. Na przykład, jeśli spróbujesz zadeklarować operator ** dla potęgowania, kompilator wygeneruje komunikat o błędzie.

Następujące cztery operatory języka C++ nie mogą być przeciążone:

// nieprzeciążalne operatory:: .* . ?:

Wstępnie zdefiniowanego przypisania operatora nie można zmienić dla typów wbudowanych. Na przykład nie można zastąpić wbudowanego operatora dodawania liczb całkowitych, aby sprawdzić wynik pod kątem przepełnienia.

// błąd: nie można przesłonić wbudowanego operatora dodawania int int operator+(int, int);

Nie można również zdefiniować dodatkowych operatorów dla wbudowanych typów danych, takich jak dodanie operatora+ do zestawu operacji wbudowanych w celu dodania dwóch tablic.

Przeciążony operator jest zdefiniowany wyłącznie dla operandów klasy lub typu wyliczenia i może być zadeklarowany tylko jako element członkowski klasy lub przestrzeni nazw, przyjmując co najmniej jeden parametr typu klasy lub wyliczenia (przekazywany przez wartość lub przez odwołanie).

Predefiniowanych pierwszeństw operatorów (patrz Sekcja 4.13) nie można zmienić. Niezależnie od typu klasy i implementacji operatora w zestawieniu

X == y + z;

operator+ jest zawsze wykonywany jako pierwszy, a następnie operator==; jednak do zmiany kolejności można użyć nawiasów.

Należy również zachować predefiniowaną aryczność operatorów. Na przykład jednoargumentowy operator logiczny NOT nie może być zdefiniowany jako operator binarny na dwóch obiektach klasy String. Następująca implementacja jest nieprawidłowa i spowoduje błąd kompilacji:

// niepoprawne: ! jest jednoargumentowym operatorem logicznym!(const String &s1, const String &s2) ( return (strcmp(s1.c_str(), s2.c_str()) != 0); )

W przypadku typów wbudowanych cztery wstępnie zdefiniowane operatory ("+", "-", "*" i "&") są używane jako operatory jednoargumentowe lub binarne. W każdej z tych cech mogą być przytłoczeni.

Wszystkie przeciążone operatory, z wyjątkiem operator(), mają nieprawidłowe argumenty domyślne.

15.1.3. Rozwój przeciążonych operatorów

Operatory przypisania i adresu oraz operator przecinka mają predefiniowane znaczenie, jeśli operandy są obiektami typu klasy. Ale mogą też być przeciążone. Semantyka wszystkich innych operatorów, zastosowana do takich operandów, musi być wyraźnie określona przez programistę. Wybór operatorów do świadczenia zależy od oczekiwanego wykorzystania klasy.

Powinieneś zacząć od zdefiniowania jego interfejsu publicznego. Zestaw publicznych funkcji składowych jest tworzony na podstawie operacji, które klasa powinna udostępniać użytkownikom. Następnie podejmowana jest decyzja, które funkcje należy zaimplementować jako operatory przeciążone.

Po zdefiniowaniu publicznego interfejsu klasy sprawdź, czy istnieje logiczna zgodność między operacjami a operatorami:

  • isEmpty() staje się operatorem LOGICAL NOT, operatorem!().
  • isEqual() staje się operatorem równości, operator==().
  • copy() staje się operatorem przypisania, operator=().

Każdy operator ma pewną naturalną semantykę. Zatem binarny + zawsze kojarzy się z dodawaniem, a jego odwzorowanie na podobną operację z klasą może być wygodnym i zwięzłym zapisem. Na przykład dla typu macierzowego dodanie dwóch macierzy jest idealnie odpowiednim rozszerzeniem plusa binarnego.

Przykładem niewłaściwego użycia przeciążania operatorów jest zdefiniowanie operatora+() jako operatora odejmowania, co jest bezcelowe: nieintuicyjna semantyka jest niebezpieczna.

Taki operator równie dobrze obsługuje kilka różnych interpretacji. Nieskazitelnie jasne i dobrze ugruntowane wyjaśnienie, co robi operator+(), raczej nie zadowoli użytkowników klasy String, którzy zakładają, że jest ona używana do łączenia ciągów. Jeśli semantyka przeciążonego operatora nie jest oczywista, lepiej jej nie podawać.

Równoważność semantyki operatora złożonego i odpowiadająca mu sekwencja operatorów prostych dla typów wbudowanych (na przykład równoważność +, po której występują = i += operatora złożonego) musi być również wyraźnie zachowana dla klasy. Załóżmy, że zarówno operator+(), jak i operator=() są zdefiniowane dla String w celu obsługi operacji łączenia i kopiowania elementów składowych:

Ciąg s1("C"); Ciąg s2("++"); s1 = s1 + s2; // s1 == "C++"

Ale to nie wystarczy do obsługi złożonego operatora przypisania

S1 += s2;

Powinna być zdefiniowana wprost, aby zachować oczekiwaną semantykę.

Ćwiczenie 15.1

Dlaczego poniższe porównanie nie wywołuje operatora przeciążonego operatora==(const String&, const String&):

"kostka" == "kamień"

Ćwiczenie 15.2

Napisz przeciążone operatory nierówności, które można wykorzystać w takich porównaniach:

String != String String != C-string C-string != String

Wyjaśnij, dlaczego zdecydowałeś się zaimplementować jedną lub więcej instrukcji.

Ćwiczenie 15.3

Zidentyfikuj te funkcje składowe klasy Screen zaimplementowane w rozdziale 13 (sekcje 13.3, 13.4 i 13.6), które mogą być przeciążone.

Ćwiczenie 15.4

Wyjaśnij, dlaczego przeciążone operatory wejścia i wyjścia zdefiniowane dla klasy String w sekcji 3.15 są deklarowane jako funkcje globalne, a nie funkcje składowe.

Ćwiczenie 15,5

Zaimplementuj przeciążone operatory wejścia i wyjścia dla klasy Screen z rozdziału 13.

15.2. Przyjaciele

Rozważ ponownie przeciążone operatory równości dla klasy String, zdefiniowane w zakresie przestrzeni nazw. Operator równości dla dwóch obiektów String wygląda tak:

Operator bool==(const String &str1, const String &str2) ( if (str1.size() != str2.size()) return false; return strcmp(str1.c_str(), str2.c_str()) ? false: PRAWDA; )

Porównaj tę definicję z definicją tego samego operatora jako funkcji składowej:

Bool String::operator==(const String &rhs) const ( if (_size != rhs._size) return false; return strcmp(_string, rhs._string) ? false: true; )

Musieliśmy zmodyfikować sposób, w jaki uzyskujemy dostęp do prywatnych członków klasy String. Ponieważ nowy operator równości to funkcja globalna, a nie funkcja członkowska, nie ma dostępu do prywatnych elementów członkowskich klasy String. Funkcje składowe size() i c_str() służą do uzyskania rozmiaru obiektu String i jego bazowego ciągu znaków C.

Alternatywną implementacją jest zadeklarowanie globalnych operatorów równości jako przyjaciół klasy String. W przypadku zadeklarowania funkcji lub operatora w ten sposób, dostęp do nich mają członkowie niepubliczni.

Deklaracja przyjaciela (która zaczyna się słowem kluczowym friend) występuje tylko w definicji klasy. Ponieważ znajomi nie są członkami klasy, która deklaruje przyjaźń, nie ma znaczenia, w której sekcji – publicznej, prywatnej czy chronionej – są deklarowani. W poniższym przykładzie zdecydowaliśmy się umieścić wszystkie takie deklaracje zaraz po nagłówku klasy:

Class String ( zaprzyjaźniony operator logiczny==(const String &, const String &); zaprzyjaźniony operator logiczny==(const char *, const String &); zaprzyjaźniony operator logiczny==(const String &, const char *); public: // ... reszta klasy String);

W tych trzech wierszach trzy przeciążone operatory porównania należące do zasięgu globalnego są zadeklarowanymi przyjaciółmi klasy String, a zatem w ich definicjach można bezpośrednio uzyskać dostęp do prywatnych członków tej klasy:

// zaprzyjaźnione operatory bezpośrednio uzyskują dostęp do prywatnych elementów // klasy String operator bool==(const String &str1, const String &str2) ( if (str1._size != str2._size) return false; return strcmp(str1._string, str2 . _string) ?false: true; ) wbudowany operator bool==(const String &str, const char *s) ( return strcmp(str._string, s) ?false: true; ) // itd.

Można argumentować, że w tym przypadku bezpośredni dostęp do składowych _size i _string nie jest konieczny, ponieważ wbudowane funkcje c_str() i size() są równie wydajne i nadal zachowują enkapsulację, co oznacza, że ​​nie ma szczególnej potrzeby aby zadeklarować operatory równości dla klasy String jako jej przyjaciół.

Skąd wiesz, czy powinieneś zaprzyjaźnić się z operatorem niebędącym członkiem klasy, czy użyć funkcji akcesorów? Generalnie deweloper powinien ograniczyć do minimum liczbę deklarowanych funkcji i operatorów, które mają dostęp do wewnętrznej reprezentacji klasy. Jeśli istnieją funkcje akcesorów, które zapewniają równą wydajność, powinny być preferowane, izolując w ten sposób operatory przestrzeni nazw przed zmianami w reprezentacji klasy, tak jak ma to miejsce w przypadku innych funkcji. Jeśli deweloper klasy nie zapewnia funkcji dostępu dla niektórych składowych, a operator zadeklarowany w przestrzeni nazw musi mieć dostęp do tych składowych, to użycie mechanizmu zaprzyjaźnionego staje się nieuniknione.

Najczęstszym zastosowaniem tego mechanizmu jest zezwolenie przeciążonym operatorom, którzy nie są członkami klasy, na dostęp do jej prywatnych członków. Gdyby nie konieczność zachowania symetrii między lewym i prawym operandem, przeciążonym operatorem byłaby funkcja składowa z pełnymi prawami dostępu.

Chociaż deklaracje znajomych są zwykle używane w odniesieniu do operatorów, zdarzają się sytuacje, gdy funkcja w przestrzeni nazw, funkcja składowa innej klasy lub nawet cała klasa muszą być zadeklarowane w ten sposób. Jeśli jedna klasa jest zaprzyjaźniona z drugą, to wszystkie funkcje członkowskie pierwszej klasy mają dostęp do niepublicznych członków drugiej. Rozważmy to na przykładzie funkcji, które nie są operatorami.

Klasa musi zadeklarować jako zaprzyjaźnioną każdą z wielu przeciążonych funkcji, którym chce nadać nieograniczone prawa dostępu:

zewnętrzny ostream& storeOn(ostream &, Screen &); zewnętrzna BitMap& storeOn(BitMap &, Screen &); // ... class Screen ( przyjaciel ostream& storeOn(ostream &, Screen &); przyjaciel BitMap& storeOn(BitMap &, Screen &); // ... );

Jeśli funkcja manipuluje obiektami dwóch różnych klas i potrzebuje dostępu do ich niepublicznych członków, to taka funkcja może być zadeklarowana jako zaprzyjaźniona obu klas lub zaprzyjaźniona z jedną i drugą.

Zadeklarowanie funkcji jako przyjaciela dwóch klas powinno wyglądać tak:

Okno klas; // to jest tylko deklaracja klasy Screen ( friend bool is_equal(Screen &, Window &); // ... ); class Window ( przyjaciel bool is_equal(Ekran &, Okno &); // ... );

Jeśli zdecydujemy się, aby funkcja była członkiem jednej klasy i przyjacielem drugiej, to deklaracje będą budowane w następujący sposób:

Okno klas; class Screen ( // copy() należy do klasy Screen Klasa Screen& copy(Window &); // ... ); class Window ( // Screen::copy() jest przyjacielem klasy Window Screen& Screen::copy(Window &); // ... ); Ekran i ekran::kopiuj(Okno i) ( /* ... */ )

Funkcja składowa jednej klasy nie może być zadeklarowana jako zaprzyjaźniona z inną, dopóki kompilator nie zobaczy definicji własnej klasy. Nie zawsze jest to możliwe. Załóżmy, że Screen musi zadeklarować niektóre funkcje składowe Window jako swoich przyjaciół, a Window musi zadeklarować niektóre funkcje składowe Screen w ten sam sposób. W tym przypadku cała klasa Window jest zaprzyjaźniona z Screen:

Okno klas; class Screen ( friend class Window; // ... );

Dostęp do prywatnych członków klasy Screen można teraz uzyskać z dowolnej funkcji członkowskiej Window.

Ćwiczenie 15,6

Zaimplementuj operatory wejścia i wyjścia zdefiniowane dla klasy Screen w ćwiczeniu 15.5 jako przyjaciele i zmodyfikuj ich definicje, aby uzyskać bezpośredni dostęp do prywatnych członków. Która implementacja jest lepsza? Wyjaśnij dlaczego.

15.3. Operator =

Przypisanie jednego obiektu do innego obiektu tej samej klasy odbywa się za pomocą operatora przypisania kopii. (Ten szczególny przypadek został omówiony w sekcji 14.7.)

Dla klasy można zdefiniować inne operatory przypisania. Jeżeli obiektom klasy trzeba przypisać wartości innego typu niż ta klasa, to można zdefiniować takie operatory, które przyjmują podobne parametry. Na przykład, aby wesprzeć przypisywanie C-string do obiektu String:

Stringcar("Volks"); samochód = "Studebaker";

udostępniamy operator, który przyjmuje parametr typu const char*. Ta operacja została już zadeklarowana w naszej klasie:

Class String ( public: // operator przypisania dla znaku* String& operator=(const char *); // ... private: int _size; char *string; );

Taki operator jest zaimplementowany w następujący sposób. Jeśli do obiektu String zostanie przypisany wskaźnik o wartości null, staje się on „pusty”. W przeciwnym razie przypisywana jest kopia ciągu C:

String& String::operator=(const char *sobj) ( // sobj jest pustym wskaźnikiem if (! sobj) ( _size = 0; usuń _string; _string = 0; ) else ( _size = strlen(sobj); usuń _string; _string = new char[ _size + 1 ]; strcpy(_string, sobj); ) return *this; )

Ciąg odnosi się do kopii ciągu C wskazywanego przez sobj. Dlaczego kopia? Ponieważ nie możesz bezpośrednio przypisać sobj do elementu _string:

Ciąg = sobj; // błąd: niezgodność typów

sobj jest wskaźnikiem do const i dlatego nie może być przypisane do wskaźnika do "non-const" (patrz Rozdział 3.5). Zmieńmy definicję operatora przypisania:

String& String::operator=(const *sobj) ( // ... )

Teraz _string bezpośrednio odnosi się do łańcucha C zaadresowanego do sobj. Rodzi to jednak inne problemy. Przypomnij sobie, że ciąg C jest typu const char*. Zdefiniowanie parametru jako wskaźnika do niestałej uniemożliwia przypisanie:

Samochód = "Studebaker"; // nieprawidłowy z operator=(char *) !

Więc nie ma wyboru. Aby przypisać C-string do obiektu typu String, parametr musi być typu const char*.

Przechowywanie w _string bezpośredniego odniesienia do łańcucha C adresowanego przez sobj powoduje inne komplikacje. Nie wiemy, na co dokładnie wskazuje sobj. Może to być tablica znaków, która jest modyfikowana w sposób nieznany obiektowi String. Na przykład:

Char ia = ( "d", "a", "n", "c", "e", "r" ); Pułapka łańcuchowa = ia; // trap._string odnosi się do ia ia = "g"; // ale nie potrzebujemy tego: // zarówno ia jak i trap._string są modyfikowane

Gdyby trap._string odwoływał się bezpośrednio do ia, wówczas obiekt pułapki wykazywałby szczególne zachowanie: jego wartość mogłaby się zmienić bez wywoływania funkcji składowych klasy String. Dlatego uważamy, że przydzielanie obszaru pamięci do przechowywania kopii wartości C-string jest mniej niebezpieczne.

Zauważ, że operator przypisania używa usuwania. Element członkowski _string zawiera odwołanie do tablicy znaków znajdujących się na stercie. Aby zapobiec wyciekowi, pamięć przydzielona dla starego ciągu jest zwalniana za pomocą polecenia delete, zanim zostanie przydzielona pamięć dla nowego. Ponieważ _string adresuje tablicę znaków, powinieneś użyć tablicy w wersji delete (patrz Rozdział 8.4).

I ostatnia uwaga o operatorze przydziału. Jego zwracanym typem jest referencja do klasy String. Dlaczego link? Faktem jest, że w przypadku typów wbudowanych operatory przypisania można łączyć w łańcuch:

// konkatenacja operatorów przypisania int iobj, jobj; iobj = pracaj = 63;

Są one powiązane od prawej do lewej, tj. w poprzednim przykładzie przypisania są wykonywane w następujący sposób:

iobj = (zadaniej = 63);

Jest to również wygodne podczas pracy z obiektami klasy String: np. obsługiwana jest następująca konstrukcja:

struna, rzeczownik; czasownik = rzeczownik = "liczba";

Pierwsze przypisanie w tym łańcuchu wywołuje wcześniej zdefiniowany operator dla const char*. Typ wyniku musi być taki, aby można go było użyć jako argumentu operatora przypisania kopii klasy String. Dlatego chociaż parametr podany operator jest typu const char *, nadal zwracane jest odwołanie do String.

Operatory przypisania są przeciążone. Na przykład nasza klasa String ma ten zestaw:

// zestaw przeciążonych operatorów przypisania String& operator=(const String &); Ciąg& operator=(const char *);

Dla każdego typu, który może być przypisany do obiektu String, może istnieć oddzielny operator przypisania. Jednak wszystkie takie operatory muszą być zdefiniowane jako funkcje składowe klasy.

15.4. Operator pobierania indeksu

Operator() indeksu można zdefiniować na klasach reprezentujących abstrakcję kontenera, z którego pobierane są poszczególne elementy. Przykładami takich kontenerów są nasza klasa String, klasa IntArray wprowadzona w rozdziale 2 lub szablon klasy wektorowej zdefiniowany w Bibliotece Standardowej C++. Operator pobierania indeksu musi być funkcją składową klasy.

Użytkownicy String muszą mieć możliwość odczytywania i pisania poszczególnych znaków elementu członkowskiego _string. Chcemy wesprzeć następujący sposób wykorzystania obiektów tej klasy:

Wpis ciągu("ekstrawagancki"); mikopia ciągu; for (int ix = 0; ix< entry.size(); ++ix) mycopy[ ix ] = entry[ ix ];

Operator indeksu dolnego może pojawić się po lewej lub po prawej stronie operatora przypisania. Aby znaleźć się po lewej stronie, musi zwrócić l-wartość indeksowanego elementu. W tym celu zwracamy referencję:

#włączać inine char& String::operator(int elem) const ( attach(elem >= 0 && elem< _size); return _string[ elem ]; }

W poniższym fragmencie null element tablicy kolorów jest przypisany do znaku „V”:

Stringcolor("fioletowy"); kolor[ 0 ] = "V";

Zauważ, że definicja operatora sprawdza, czy indeks wykracza poza granice tablicy. Służy do tego funkcja z biblioteki C attach(). Możliwe jest również zgłoszenie wyjątku wskazującego, że wartość elem jest mniejsza niż 0 lub większa niż długość ciągu C, do którego odwołuje się _string. (Zgłaszanie i obsługa wyjątków omówiono w rozdziale 11.)

15.5. Operator wywołania funkcji

Operator wywołania funkcji może być przeciążony dla obiektów typu klasa. (Widzieliśmy już, jak jest używany podczas omawiania obiektów funkcji w Sekcja 12.3). Jeśli zdefiniowana jest klasa, która reprezentuje operację, odpowiedni operator jest przeciążany, aby ją wywołać. Na przykład, aby przyjąć wartość bezwzględną liczby typu int, możesz zdefiniować klasę absInt:

Klasa absInt ( public: int operator()(int val) ( int wynik = val< 0 ? -val: val; return result; } };

Przeciążony operator() musi być zadeklarowany jako funkcja członkowska z dowolną liczbą parametrów. Parametry i wartości zwracane mogą być dowolnego typu dozwolonego dla funkcji (patrz sekcje 7.2, 7.3 i 7.4). operator() jest wywoływany przez zastosowanie listy argumentów do obiektu klasy, w której jest zdefiniowany. Przyjrzymy się, jak jest używany w jednym z uogólnionych algorytmów opisanych w rozdziale. W poniższym przykładzie wywoływany jest ogólny algorytm transform() w celu zastosowania operacji zdefiniowanej na absInt do każdego elementu wektora ivec, tj. zastąpić element jego wartością bezwzględną.

#włączać #włączać int main() ( int ia = ( -0, 1, -1, -2, 3, 5, -5, 8 ); wektor ivec(ia, ia+8); // zastąp każdy element jego wartością bezwzględną transform(ivec.begin(), ivec.end(), ivec.begin(), absInt()); // ... )

Pierwszy i drugi argument transform() ograniczają zakres elementów, do których stosowana jest operacja absInt. Trzeci wskazuje na początek wektora, w którym zostanie zapisany wynik wykonania operacji.

Czwarty argument to tymczasowy obiekt klasy absInt, który jest tworzony przy użyciu domyślnego konstruktora. Instancja uogólnionego algorytmu transform() wywoływanego z main() może wyglądać tak:

wektor typedef ::iterator iter_type; // instancja transform() // operacja absInt jest stosowana do elementu wektora int iter_type transform(iter_type iter, iter_type last, iter_type wynik, absInt func) ( while (iter != last) *result++ = func(*iter++) ; // wywołane absInt::operator() zwraca iter; )

func to obiekt klasy udostępniający operację absInt, która zastępuje int jego wartością bezwzględną. Służy do wywołania przeciążonego operatora() klasy absInt. Do tego operatora przekazywany jest argument *iter, wskazujący na element wektora, dla którego chcemy uzyskać wartość bezwzględną.

15.6. operator strzałki

Operator strzałki, który umożliwia dostęp do elementów członkowskich, może być przeciążony dla obiektów klasy. Musi być zdefiniowany jako funkcja członkowska i zapewniać semantykę wskaźnika. Najczęstszym zastosowaniem tego operatora są klasy, które udostępniają „inteligentny wskaźnik”, który zachowuje się podobnie do wbudowanych, ale zapewnia pewną dodatkową funkcjonalność.

Powiedzmy, że chcemy zdefiniować typ klasy reprezentujący wskaźnik do obiektu Screen (patrz rozdział 13):

Klasa ScreenPtr ( // ... private: Screen *ptr; );

Definicja ScreenPtr musi być taka, aby obiekt tej klasy na pewno wskazywał na obiekt Screen: w przeciwieństwie do wbudowanego wskaźnika, nie może on mieć wartości NULL. Aplikacja może następnie używać obiektów typu ScreenPtr bez sprawdzania, czy wskazują one na dowolny obiekt Screen. W tym celu należy zdefiniować klasę ScreenPtr z konstruktorem, ale bez konstruktora domyślnego (konstruktory zostały szczegółowo omówione w rozdziale 14.2):

Klasa ScreenPtr ( public: ScreenPtr(const Screen &s) : ptr(&s) ( ) // ... );

Każda definicja obiektu klasy ScreenPtr musi zawierać inicjator - obiekt klasy Screen, do którego będzie się odwoływał obiekt ScreenPtr:

ScreenPtr p1; // błąd: klasa ScreenPtr nie ma domyślnego konstruktora Screen myScreen(4, 4); ScreenPtr ps(mojEkran); // prawo

Aby klasa ScreenPtr zachowywała się jak wbudowany wskaźnik, konieczne jest zdefiniowanie kilku przeciążonych operatorów - dereferencja (*) i „strzałka” dostępu do składowych:

// przeciążone operatory obsługujące zachowanie wskaźnika class ScreenPtr ( public: Screen& operator*() ( return *ptr; ) Screen* operator->() ( return ptr; ) // ... ); Operator dostępu do elementu członkowskiego jest jednoargumentowy, więc nie są do niego przekazywane żadne parametry. Gdy jest używany jako część wyrażenia, jego wynik zależy tylko od typu lewego operandu. Na przykład w point->action(); sprawdzany jest typ punktu. Jeśli jest to wskaźnik do pewnego typu klasy, obowiązuje semantyka wbudowanego operatora dostępu do składowej. Jeśli jest to obiekt lub odwołanie do obiektu, sprawdzane jest, czy w tej klasie nie ma przeciążonego operatora dostępu. Gdy zdefiniowany jest przeciążony operator strzałki, jest on wywoływany na obiekcie punktu, w przeciwnym razie instrukcja jest nieważna, ponieważ operator punktu musi być używany do odwoływania się do elementów samego obiektu (w tym przez odwołanie). Przeciążony operator strzałki musi zwracać wskaźnik do typu klasy lub obiektu klasy, w której jest zdefiniowany. Jeśli zwracany jest wskaźnik, stosuje się do niego semantykę wbudowanego operatora strzałki. W przeciwnym razie proces jest kontynuowany rekursywnie do momentu uzyskania wskaźnika lub wykrycia błędu. Na przykład w ten sposób możesz użyć obiektu ps klasy ScreenPtr, aby uzyskać dostęp do elementów Screen: ps->move(2, 3); Ponieważ na lewo od operatora „strzałka” znajduje się obiekt typu ScreenPtr, używany jest przeciążony operator tej klasy, który zwraca wskaźnik do obiektu Screen. Wbudowany operator strzałki jest następnie stosowany do pobranej wartości, aby wywołać funkcję członkowską move(). Poniżej znajduje się mały program aby przetestować klasę ScreenPtr. Obiekt typu ScreenPtr jest używany w taki sam sposób jak dowolny obiekt typu Screen*: #include #włączać #include "Screen.h" void printScreen(const ScreenPtr &ps) ( cout<< "Screen Object (" << ps->wzrost()<< ", " << ps->szerokość()<< ")\n\n"; for (int ix = 1; ix <= ps->wzrost(); ++ix) ( dla (int iy = 1; iy<= ps->szerokość(); ++iy) cout<pobierz(ix, iy); Cout<< "\n"; } } int main() { Screen sobj(2, 5); string init("HelloWorld"); ScreenPtr ps(sobj); // Установить содержимое экрана string::size_type initpos = 0; for (int ix = 1; ix <= ps->wzrost(); ++ix) dla (int iy = 1; iy<= ps->szerokość(); ++iy) ( ps->move(ix, iy); ps->set(init[ initpos++ ]); ) // Drukuj zawartość ekranu printScreen(ps); zwróć 0; )

Oczywiście takie manipulacje wskaźnikami do obiektów klas nie są tak wydajne, jak praca z wbudowanymi wskaźnikami. Dlatego inteligentny wskaźnik musi zapewniać dodatkową funkcjonalność, która jest ważna dla aplikacji, aby uzasadnić złożoność jego użycia.

15.7. Operatory inkrementacji i dekrementacji

Kontynuując rozwój implementacji klasy ScreenPtr wprowadzonej w poprzedniej sekcji, spójrzmy na dwa kolejne operatory, które są obsługiwane dla wskaźników wbudowanych i które nasz inteligentny wskaźnik chciałby mieć: inkrementacja (++) i dekrementacja (--) . Aby użyć klasy ScreenPtr do odwoływania się do elementów tablicy obiektów Screen, musisz dodać kilka dodatkowych członków.

Najpierw definiujemy nowy element, size, który wynosi zero (wskazując, że obiekt ScreenPtr wskazuje na pojedynczy obiekt) lub rozmiar tablicy wskazywanej przez obiekt ScreenPtr. Potrzebujemy również składowej offsetu, która zapamiętuje offset z początku danej tablicy:

Class ScreenPtr ( public: // ... private: int size; // array size: 0 jeśli jedynym obiektem jest int offset; // offset ptr od początku tablicy Screen *ptr; );

Zmodyfikuj konstruktor klasy ScreenPtr, aby odzwierciedlić jego nową funkcjonalność i dodatkowe elementy członkowskie. Użytkownik naszej klasy musi przekazać konstruktorowi dodatkowy argument, jeśli tworzony obiekt wskazuje na tablicę:

Klasa ScreenPtr ( public: ScreenPtr(Screen &s , int arraySize = 0) : ptr(&s), size (arraySize), offset(0) ( ) private: int size; int offset; Screen *ptr; );

Ten argument określa rozmiar tablicy. Aby zachować tę samą funkcjonalność, podajmy domyślną wartość zero. Tak więc, jeśli drugi argument konstruktora zostanie pominięty, wtedy składowa size będzie wynosić 0, a zatem taki obiekt będzie wskazywał na pojedynczy obiekt Screen. Obiekty nowej klasy ScreenPtr można zdefiniować w następujący sposób:

Ekran mojeEkran(4, 4); ScreenPtr pobj(mojEkran); // poprawnie: wskazuje na jeden obiekt const int arrSize = 10; Screen *parray = new Screen[arrSize]; ScreenPtr parr(*parray, arrSize); // poprawnie: wskazuje na tablicę

Jesteśmy teraz gotowi do zdefiniowania przeciążonych operatorów inkrementacji i dekrementacji w ScreenPtr. Są jednak dwojakiego rodzaju: prefiks i postfiks. Na szczęście można zdefiniować obie opcje. W przypadku operatora przedrostkowego deklaracja nie zawiera niczego nieoczekiwanego:

Klasa ScreenPtr ( public: ekran& operator++(); ekran& operator--(); // ... );

Takie operatory są zdefiniowane jako jednoargumentowe funkcje operatorskie. Możesz użyć operatora inkrementacji prefiksu, na przykład w następujący sposób: const int arrSize = 10; Screen *parray = new Screen[arrSize]; ScreenPtr parr(*parray, arrSize); for (int ix = 0; ix

Definicje tych przeciążonych operatorów podano poniżej:

Screen& ScreenPtr::operator++() ( if (rozmiar == 0) ( cerr<<"не могу инкрементировать указатель для одного объекта\n"; return *ptr; } if (offset >= rozmiar - 1) ( cerr<< "уже в конце массива\n"; return *ptr; } ++offset; return *++ptr; } Screen& ScreenPtr::operator--() { if (size == 0) { cerr << "не могу декрементировать указатель для одного объекта\n"; return *ptr; } if (offset <= 0) { cerr << "уже в начале массива\n"; return *ptr; } --offset; return *--ptr; }

Aby odróżnić operatory przedrostkowe od operatorów przyrostkowych, deklaracje tych ostatnich mają dodatkowy parametr typu int. Poniższy fragment deklaruje wersje przedrostkowe i przyrostkowe operatorów inkrementacji i dekrementacji dla klasy ScreenPtr:

Klasa ScreenPtr ( public: Screen& operator++(); // operatory przedrostkowe Screen& operator--(); Screen& operator++(int); // operatory przyrostkowe Screen& operator--(int); // ... );

Poniżej znajduje się możliwa implementacja operatorów postfiksowych:

Screen& ScreenPtr::operator++(int) ( if (size == 0) ( cerr<< "не могу инкрементировать указатель для одного объекта\n"; return *ptr; } if (offset == size) { cerr << "уже на один элемент дальше конца массива\n"; return *ptr; } ++offset; return *ptr++; } Screen& ScreenPtr::operator--(int) { if (size == 0) { cerr << "не могу декрементировать указатель для одного объекта\n"; return *ptr; } if (offset == -1) { cerr <<"уже на один элемент раньше начала массива\n"; return *ptr; } --offset; return *ptr--; }

Należy zauważyć, że nazwa drugiego parametru nie jest konieczna, ponieważ nie jest on używany w definicji operatora. Sam kompilator zastępuje go wartością domyślną, którą można zignorować. Oto przykład użycia operatora postfix:

Const int arrSize = 10; Screen *parray = new Screen[arrSize]; ScreenPtr parr(*parray, arrSize); for (int ix = 0; ix

Jeśli wywołasz to jawnie, nadal musisz przekazać wartość drugiego argumentu będącego liczbą całkowitą. W przypadku naszej klasy ScreenPtr ta wartość jest ignorowana, więc może to być dowolna:

parr.operator++(1024); // zadzwoń do operatora postfiksu++

Przeciążone operatory inkrementacji i dekrementacji mogą być deklarowane jako funkcje zaprzyjaźnione. Zmień odpowiednio definicję klasy ScreenPtr:

Class ScreenPtr ( // deklaracje niebędące członkami friend Screen& operator++(Ekran &); // operatory prefiksowe friend Screen& operator--(Ekran &); friend Screen& operator++(Ekran &, int); // operatory przyrostkowe friend Screen& operator-- ( Screen &, int); public: // definicje elementów członkowskich );

Ćwiczenie 15,7

Napisz definicje przeciążonych operatorów inkrementacji i dekrementacji dla klasy ScreenPtr, zakładając, że są one zadeklarowanymi przyjaciółmi klasy.

Ćwiczenie 15.8

ScreenPtr może służyć do reprezentowania wskaźnika do tablicy obiektów klasy Screen. Zmodyfikuj przeciążony operator*() i operator >() (zobacz Sekcja 15.6) tak, aby wskaźnik w żadnym wypadku nie odnosił się do elementu przed ani za końcem tablicy. Wskazówka: Operatorzy ci powinni używać nowych elementów rozmiaru i przesunięcia.

15.8. Operatorzy nowe i usunięte

Domyślnie alokowanie obiektu klasy ze sterty i zwalnianie zajmowanej przez niego pamięci odbywa się za pomocą globalnych operatorów new() i delete() zdefiniowanych w Bibliotece standardowej C++. (Omówiliśmy te operatory w sekcji 8.4.) Ale klasa może również zaimplementować własną strategię zarządzania pamięcią, dostarczając operatory członkowskie o tej samej nazwie. Jeśli są zdefiniowane w klasie, są wywoływane zamiast operatorów globalnych, aby alokować i zwalniać pamięć dla obiektów tej klasy.

Zdefiniujmy operatory new() i delete() w naszej klasie Screen.

Operator członkowski new() musi zwrócić wartość typu void* i jako pierwszy parametr przyjąć wartość typu size_t, gdzie size_t jest typem zdefiniowanym w systemowym pliku nagłówkowym. Oto jego ogłoszenie:

Kiedy new() jest używane do tworzenia obiektu typu klasy, kompilator sprawdza, czy klasa ma zdefiniowany taki operator. Jeśli tak, to obiekt jest wywoływany w celu alokacji pamięci dla obiektu, w przeciwnym razie wywoływany jest operator globalny new(). Na przykład następujące stwierdzenie

Ekran *ps = nowy Ekran;

tworzy obiekt Screen na stercie, a ponieważ ta klasa ma operator new(), jest wywoływana. Parametr size_t operatora jest automatycznie inicjowany do rozmiaru ekranu w bajtach.

Dodawanie lub usuwanie new() do lub z klasy nie ma wpływu na kod użytkownika. Wywołanie new wygląda tak samo dla operatora globalnego i operatora członkowskiego. Gdyby klasa Screen nie miała własnego new(), wywołanie pozostałoby poprawne, a zamiast operatora składowego zostałby wywołany tylko operator globalny.

Za pomocą operatora globalnego rozpoznawania zakresu możesz wywołać funkcję global new(), nawet jeśli klasa Screen ma zdefiniowaną własną wersję:

Ekran *ps = ::nowy Ekran;

Gdy operand usuwania jest wskaźnikiem do obiektu typu klasy, kompilator sprawdza, czy operator delete() jest zdefiniowany w tej klasie. Jeśli tak, to to on jest wzywany do zwolnienia pamięci, w przeciwnym razie globalnej wersji operatora. Następna instrukcja

Usuń ps;

zwalnia pamięć zajmowaną przez obiekt Screen wskazywany przez ps. Ponieważ Screen ma operator członkowski delete(), to właśnie on ma zastosowanie. Parametr operatora typu void* jest automatycznie inicjowany do ps. Dodanie delete() do klasy lub usunięcie jej z niej nie ma wpływu na kod użytkownika. Wywołanie usunięcia wygląda tak samo dla operatora globalnego i operatora członkowskiego. Gdyby klasa Screen nie miała własnego operatora delete(), wywołanie pozostałoby poprawne, a zamiast operatora składowego zostałby wywołany tylko operator globalny.

Za pomocą globalnego operatora rozpoznawania zakresu możesz wywołać globalną funkcję delete(), nawet jeśli Screen ma zdefiniowaną własną wersję:

::usuń ps;

Ogólnie rzecz biorąc, użyty operator delete() musi odpowiadać operatorowi new(), który przydzielił pamięć. Na przykład, jeśli ps wskazuje na obszar pamięci zaalokowany przez globalne new(), to globalne delete() powinno być użyte do jego zwolnienia.

Operator delete() zdefiniowany dla typu klasy może mieć dwa parametry zamiast jednego. Pierwszy parametr musi nadal być typu void*, a drugi musi być predefiniowanego typu size_t (nie zapomnij dołączyć pliku nagłówkowego):

Class Screen ( public: // zastępuje // void operator delete(void *); void operator delete(void *, size_t); );

Jeśli istnieje drugi parametr, kompilator automatycznie inicjuje go z wartością równą rozmiarowi w bajtach obiektu adresowanego przez pierwszy parametr. (Ta opcja jest ważna w hierarchii klas, gdy operator delete() może być dziedziczony przez klasę pochodną. Więcej o dziedziczeniu omówiono w rozdziale).

Przyjrzyjmy się bardziej szczegółowo implementacji operatorów new() i delete() w klasie Screen. Nasza strategia alokacji pamięci będzie oparta na połączonej liście obiektów Screen, na którą wskazuje członek freeStore. Za każdym razem, gdy wywoływany jest operator członkowski new(), zwracany jest następny obiekt na liście. Po wywołaniu delete() obiekt jest zwracany na listę. Jeśli podczas tworzenia nowego obiektu lista adresowana do freeStore jest pusta, wówczas wywoływany jest operator globalny new() w celu uzyskania bloku pamięci wystarczająco dużego do przechowywania obiektów screenChunk klasy Screen.

Zarówno screenChunk, jak i freeStore są interesujące tylko dla Screen, więc uczynimy je prywatnymi członkami. Dodatkowo dla wszystkich tworzonych obiektów naszej klasy wartości tych elementów muszą być takie same, a co za tym idzie muszą być zadeklarowane jako statyczne. Aby wesprzeć połączoną strukturę listy obiektów Screen, potrzebujemy trzeciego kolejnego członka:

Class Screen ( public: void *operator new(size_t); void operator delete(void *, size_t); // ... private: Screen *next; static Screen *freeStore; static const int screenChunk; );

Oto jedna z możliwych implementacji operatora new() dla klasy Screen:

#include "Screen.h" #include // elementy statyczne są inicjowane // w plikach źródłowych programu, a nie w plikach nagłówkowych Screen *Screen::freeStore = 0; const int Screen::screenChunk = 24; void *Screen::operator new(size_t size) ( Screen *p; if (!freeStore) ( // połączona lista jest pusta: pobierz nowy blok // globalny operator nazywa się new size_t chunk = screenChunk * size; freeStore = p = reinterpretuj_cast< Screen* >(nowy znak[fragment]); // dołącz wynikowy blok do listy dla (; p != &freeStore[ screenChunk - 1 ]; ++p) p->next = p+1; p->następny = 0; ) p = FreeStore; freeStore = freeStore->następny; powrót p; ) A oto implementacja operatora delete(): void Screen::operator delete(void *p, size_t) ( // wstaw "usunięty" obiekt z powrotem, // do wolnej listy (static_cast< Screen* >(p))->następny = freeStore; freeStore = static_cast< Screen* >(p); )

Operator new() można zadeklarować w klasie bez odpowiadającej mu funkcji delete(). W takim przypadku obiekty są zwalniane przy użyciu globalnego operatora o tej samej nazwie. Dozwolone jest również zadeklarowanie operatora delete() bez new(): obiekty będą tworzone przy użyciu operatora globalnego o tej samej nazwie. Jednak te operatory są zwykle implementowane w tym samym czasie, jak w powyższym przykładzie, ponieważ projektant klasy zazwyczaj potrzebuje obu.

Są statycznymi członkami klasy, nawet jeśli programista nie deklaruje ich jawnie jako takich, i podlegają zwykłym ograniczeniom dla takich funkcji składowych: nie są przekazywane przez wskaźnik this, a zatem mają tylko bezpośredni dostęp członkowie statyczni. (Patrz omówienie statycznych funkcji składowych w rozdziale 13.5) Powodem, dla którego te operatory są statyczne, jest to, że są wywoływane albo przed skonstruowaniem obiektu klasy (new()), albo po jego zniszczeniu (delete()).

Alokacja pamięci za pomocą operatora new(), na przykład:

Ekran *ptr = nowy ekran(10, 20);

// pseudokod C++ ptr = Screen::operator new(sizeof(Screen)); Ekran::Ekran(ptr, 10, 20);

Innymi słowy, operator new() zdefiniowany w klasie jest najpierw wywoływany w celu alokacji pamięci dla obiektu, a następnie ten obiekt jest inicjowany za pomocą konstruktora. Jeśli new() nie powiedzie się, zgłaszany jest wyjątek typu bad_alloc i konstruktor nie jest wywoływany.

Zwalnianie pamięci za pomocą operatora delete(), na przykład:

Usuń pkt;

jest równoznaczne z wykonaniem kolejno następujących instrukcji:

// Pseudokod C++ Screen::~Screen(ptr); Screen::operator delete(ptr, sizeof(*ptr));

Tak więc, gdy obiekt zostanie zniszczony, najpierw wywoływany jest destruktor klasy, a następnie operator delete() zdefiniowany w klasie jest wywoływany w celu zwolnienia pamięci. Jeśli ptr wynosi 0, to nie jest wywoływany ani destruktor, ani delete().

15.8.1. Operatorzy nowe i usunięte

Operator new(), zdefiniowany w poprzedniej podsekcji, jest wywoływany tylko wtedy, gdy pamięć jest przydzielona dla pojedynczego obiektu. Tak więc w tej instrukcji new() klasy Screen nosi nazwę:

// Screen::operator new() nazywa się Screen *ps = new Screen(24, 80);

podczas gdy globalny operator new() jest wywoływany poniżej, aby alokować pamięć ze sterty dla tablicy obiektów typu Screen:

// Screen::operator new() nazywa się Screen *psa = new Screen;

Klasa może również deklarować operatory new() i delete() do pracy z tablicami.

Operator członkowski new() musi zwracać wartość typu void* i jako pierwszy parametr przyjmować wartość typu size_t. Oto jego deklaracja dla Screena:

Class Screen ( public: void *operator new(size_t); // ... );

Używając new do tworzenia tablicy obiektów typu klasy, kompilator sprawdza, czy operator new() jest zdefiniowany w klasie. Jeśli tak, to tablica jest wywoływana w celu alokacji pamięci dla tablicy, w przeciwnym razie wywoływana jest globalna new(). Poniższa instrukcja tworzy w biodrze tablicę dziesięciu obiektów Screen:

Ekran *ps = nowy Ekran;

Ta klasa ma operator new(), dlatego jest wywoływana w celu alokacji pamięci. Jego parametr size_t jest automatycznie inicjowany do ilości pamięci, w bajtach, potrzebnej do przechowywania dziesięciu obiektów Screen.

Nawet jeśli klasa ma operator składowy new(), programista może wywołać funkcję global new() w celu utworzenia tablicy przy użyciu operatora globalnego rozpoznawania zasięgu:

Ekran *ps = ::nowy Ekran;

Operator delete(), który należy do klasy, musi być typu void i jako pierwszy parametr przyjmować void*. Oto jak wygląda jego deklaracja dla Screena:

Class Screen ( public: void operator delete(void *); );

Aby usunąć tablicę obiektów klas, należy wywołać delete w ten sposób:

Usuń ps;

Gdy operand usuwania jest wskaźnikiem do obiektu typu klasy, kompilator sprawdza, czy operator delete() jest zdefiniowany w tej klasie. Jeśli tak, to to on jest powołany do uwolnienia pamięci, w przeciwnym razie jego globalnej wersji. Parametr typu void* jest automatycznie inicjowany na adres początku obszaru pamięci, w którym znajduje się tablica.

Nawet jeśli klasa ma operator składowy delete(), programista może wywołać globalną funkcję delete() przy użyciu operatora globalnego rozpoznawania zasięgu:

::usuń ps;

Dodanie lub usunięcie operatorów new() lub delete() do klasy nie ma wpływu na kod użytkownika: wywołania operatorów globalnych i operatorów członkowskich wyglądają tak samo.

Kiedy tworzona jest tablica, najpierw wywoływana jest metoda new(), aby przydzielić niezbędną pamięć, a następnie każdy element jest inicjowany za pomocą domyślnego konstruktora. Jeśli klasa ma co najmniej jeden konstruktor, ale nie ma konstruktora domyślnego, wywołanie operatora new() jest uważane za błąd. Podczas tworzenia tablicy w ten sposób nie ma składni określającej inicjatory elementów tablicy lub argumenty konstruktora klasy.

Kiedy tablica zostanie zniszczona, najpierw wywoływany jest destruktor klasy w celu zniszczenia elementów, a następnie wywoływany jest operator delete() w celu zwolnienia całej pamięci. Podczas wykonywania tego ważne jest, aby używać prawidłowej składni. Jeśli instrukcje

Usuń ps;

ps wskazuje na tablicę obiektów klas, wtedy brak nawiasów kwadratowych spowoduje, że destruktor zostanie wywołany tylko dla pierwszego elementu, chociaż pamięć zostanie całkowicie zwolniona.

Operator składowy delete() może mieć nie jeden, ale dwa parametry, przy czym drugi jest typu size_t:

Class Screen ( public: // zastępuje // void operator delete(void*); void operator delete(void*, size_t); );

Jeśli drugi parametr jest obecny, kompilator automatycznie inicjuje go z wartością równą ilości pamięci przydzielonej dla tablicy w bajtach.

15.8.2. Operator miejsca new() i operator delete()

Operator składowy new() może być przeciążony, pod warunkiem, że wszystkie deklaracje mają różne listy parametrów. Pierwszy parametr musi być typu size_t:

Class Screen ( public: void *operator nowy(rozmiar_t); void *operator nowy(rozmiar_t, ekran *); // ... );

Pozostałe parametry są inicjowane argumentami położenia podanymi podczas wywoływania new:

Void func(Ekran *start) ( // ... )

Część wyrażenia występująca po słowie kluczowym new i ujęta w nawiasy reprezentuje argumenty miejsca docelowego. Powyższy przykład wywołuje operator new(), który przyjmuje dwa parametry. Pierwsza jest automatycznie inicjowana do rozmiaru klasy Screen w bajtach, a druga do wartości argumentu początkowego umieszczenia.

Możesz także przeciążyć operator członkowski delete(). Jednak taki operator nigdy nie jest wywoływany z wyrażenia usuwania. Przeciążona metoda delete() jest niejawnie wywoływana przez kompilator, jeśli konstruktor wywoływany podczas wykonywania operatora new (to nie jest literówka, naprawdę mamy na myśli nowy) zgłasza wyjątek. Przyjrzyjmy się bliżej użyciu funkcji delete().

Sekwencja działań podczas oceny wyrażenia

Ekran *ps = nowy (start) Ekran;

  1. Wywoływany jest operator new(size_t, Screen*) zdefiniowany w klasie.
  2. Domyślny konstruktor klasy Screen jest wywoływany w celu zainicjowania utworzonego obiektu.

Zmienna ps jest inicjowana adresem nowego obiektu Screen.

Załóżmy, że operator klasy new(size_t, Screen*) przydziela pamięć za pomocą globalnego new(). W jaki sposób deweloper może zapewnić zwolnienie pamięci, jeśli konstruktor wywołany w kroku 2 zgłosi wyjątek? Aby chronić kod użytkownika przed wyciekami pamięci, należy podać przeciążony operator delete(), który jest wywoływany tylko w tej sytuacji.

Jeśli klasa ma przeciążony operator z parametrami, których typy są zgodne z typami parametrów new(), kompilator automatycznie wywołuje go w celu zwolnienia pamięci. Załóżmy, że mamy następujące wyrażenie z operatorem rozmieszczenia new:

Ekran *ps = nowy (start) Ekran;

Jeśli domyślny konstruktor klasy Screen zgłosi wyjątek, kompilator szuka funkcji delete() w zakresie Screen. Aby taki operator został znaleziony, typy jego parametrów muszą odpowiadać typom parametrów wywołanej new(). Ponieważ pierwszy parametr new() jest zawsze typu size_t, a operator delete() jest zawsze void*, pierwsze parametry nie są brane pod uwagę podczas porównywania. Kompilator szuka w klasie Screen operatora delete() o następującej postaci:

void operator usuń(void*, Screen*);

Jeśli taki operator zostanie znaleziony, jest wywoływany w celu zwolnienia pamięci w przypadku, gdy new() wyrzuci wyjątek. (W przeciwnym razie nie jest nazywany.)

Projektant klasy decyduje, czy dostarczyć delete() odpowiadającego pewnemu new(), w zależności od tego, czy ten operator new() sam alokuje pamięć, czy używa już przydzielonej pamięci. W pierwszym przypadku należy dołączyć delete(), aby zwolnić pamięć, jeśli konstruktor zgłasza wyjątek; w przeciwnym razie nie jest to potrzebne.

Możesz także przeciążyć operator alokacji new() i operator delete() dla tablic:

Class Screen ( public: void *operator new(size_t); void *operator new(size_t, Screen*); void operator delete(void*, size_t); void operator delete(void*, Screen*); // ... );

Operator new() jest używany, gdy wyrażenie zawierające new do alokacji tablicy ma odpowiednie argumenty alokacji:

Void func(Screen *start) ( // wywołanie Screen::operator new(size_t, Screen*) Screen *ps = new (start) Screen; // ... )

Jeśli konstruktor zgłosi wyjątek podczas operacji nowego operatora, automatycznie wywoływana jest odpowiednia metoda delete().

Ćwiczenie 15,9

Wyjaśnij, które z poniższych inicjalizacji są nieprawidłowe:

Klasa iStack ( public: iStack(pojemność int) : _stack(pojemność), _top(0) () // ... private: int _top; vatcor< int>_stos; ); (a) iStack *ps = nowy iStack(20); (b) iStack *ps2 = nowy const iStack(15); (c) iStack *ps3 = nowy iStack[ 100 ];

Ćwiczenie 15.10

Co dzieje się z następującymi wyrażeniami zawierającymi new i delete?

Ćwiczenie klasowe ( public: Exercise(); ~Exercise(); ); Ćwiczenie *pe = nowe Ćwiczenie; usuń ps;

Zmodyfikuj te wyrażenia tak, aby wywoływane były globalne operatory new() i delete().

Ćwiczenie 15.11

Wyjaśnij, dlaczego projektant klas powinien udostępnić operator delete().

15.9. Konwersje zdefiniowane przez użytkownika

Widzieliśmy już, jak konwersje typów są stosowane do operandów typów wbudowanych: w rozdziale 4.14 problem ten rozważano na przykładzie operandów operatorów wbudowanych, a w rozdziale 9.3 na przykładzie rzeczywistych argumentów wywołana funkcja do rzutowania ich na typy parametrów formalnych. Rozważ następujące sześć operacji dodawania z tego punktu widzenia:

Charch; krótkie sh;, int ival; /* jeden operand na operację * wymaga konwersji typu */ ch + ival; iwal + ch; ch+sz; ch+ch; iwal + sz; sh + iwal;

Operandy ch i sh są rozwinięte do typu int. Podczas wykonywania operacji dodawane są dwie wartości typu int. Rozszerzanie typu jest niejawnie wykonywane przez kompilator i jest niewidoczne dla użytkownika.

W tej sekcji przyjrzymy się, jak programista może zdefiniować niestandardowe konwersje dla obiektów typu klasy. Takie konwersje zdefiniowane przez użytkownika są również automatycznie wywoływane przez kompilator w razie potrzeby. Aby pokazać, dlaczego są potrzebne, wróćmy do klasy SmallInt wprowadzonej w rozdziale 10.9.

Przypomnijmy, że SmallInt pozwala zdefiniować obiekty, które mogą przechowywać wartości z tego samego zakresu co unsigned char, czyli np. od 0 do 255 i wychwytuje błędy wykraczające poza granice. Pod wszystkimi innymi względami ta klasa zachowuje się dokładnie jak unsigned char.

Aby móc dodawać i odejmować obiekty SmallInt do innych obiektów tej samej klasy lub do wartości typów wbudowanych, implementujemy sześć funkcji operatorskich:

Class SmallInt ( operator przyjaciela+(const SmallInt &, int); operator przyjaciela-(const SmallInt &, int); operator przyjaciela-(int, const SmallInt &); operator przyjaciela+(int, const SmallInt &); public: SmallInt(int ival) : wartość(ival) ( ) operator+(const SmallInt &);operator-(const SmallInt &); // ... prywatne: int wartość; );

Operatory członkowskie zapewniają możliwość dodawania i odejmowania dwóch obiektów SmallInt. Globalne operatory zaprzyjaźnione umożliwiają wykonywanie tych operacji na obiektach danej klasy oraz obiektach wbudowanych typów arytmetycznych. Potrzebnych jest tylko sześciu operatorów, ponieważ każdy wbudowany typ arytmetyczny może być rzutowany na int. Na przykład wyrażenie

rozwiązany w dwóch krokach:

  1. Podwójna stała 3,14159 jest konwertowana na liczbę całkowitą 3.
  2. Wywoływany jest operator+(const SmallInt &,int), który zwraca wartość 6.

Jeśli chcemy obsługiwać operacje bitowe i logiczne, a także operatory porównania i przypisania złożonego, to ile operatorów musi być przeciążonych? Nie liczysz od razu. O wiele wygodniej jest automatycznie przekonwertować obiekt klasy SmallInt na obiekt typu int.

Język C++ posiada mechanizm, który pozwala każdej klasie określić zestaw przekształceń mających zastosowanie do jej obiektów. Dla SmallInt zdefiniujemy rzutowanie obiektu na int. Oto jego realizacja:

Class SmallInt ( public: SmallInt(int ival) : value(ival) ( ) // konwerter // SmallInt ==> operator int int() ( zwraca wartość; ) // nie ma potrzeby stosowania przeciążonych operatorów private: int wartość; );

Operator int() jest konwerterem, który implementuje konwersję zdefiniowaną przez użytkownika, w tym przypadku rzutowanie typu klasy na dany typ int. Definicja konwertera opisuje, co oznacza konwersja i jakie działania musi wykonać kompilator, aby ją zastosować. W przypadku obiektu SmallInt punktem konwersji na int jest zwrócenie liczby typu int przechowywanego w elemencie członkowskim wartości.

Teraz obiekt klasy SmallInt może być używany wszędzie tam, gdzie dozwolone jest int. Zakładając, że nie ma już przeciążonych operatorów, a SmallInt ma konwerter na int, operacja dodawania

SmallInt si(3); si+3,14159

rozwiązany w dwóch krokach:

  1. Konwerter klasy SmallInt jest wywoływany i zwraca liczbę całkowitą 3.
  2. Liczba całkowita 3 jest rozszerzana do 3,0 i dodawana do stałej podwójnej precyzji 3,14159, co daje 6,14159.

To zachowanie jest bardziej zgodne z zachowaniem wbudowanych operandów typu w porównaniu z wcześniej zdefiniowanymi operatorami przeciążonymi. Kiedy int jest dodawany do double, dodawane są dwa double (ponieważ int rozwija się do double), a wynikiem jest liczba tego samego typu.

Ten program ilustruje użycie klasy SmallInt:

#włączać #include "SmallInt.h" int main() ( cout<< "Введите SmallInt, пожалуйста: "; while (cin >> si1) ( cout<< "Прочитано значение " << si1 << "\nОно "; // SmallInt::operator int() вызывается дважды cout << ((si1 >127)? "większe niż" : ((si1< 127) ? "меньше, чем " : "равно ")) <<"127\n"; cout << "\Введите SmallInt, пожалуйста \ (ctrl-d для выхода): "; } cout <<"До встречи\n"; }

Skompilowany program daje następujące wyniki:

Proszę wpisać SmallInt: 127

Odczytaj wartość 127

Jest równy 127

Wpisz SmallInt proszę (ctrl-d, aby wyjść): 126

To mniej niż 127

Wprowadź SmallInt proszę (ctrl-d, aby wyjść): 128

To ponad 127

Wpisz SmallInt proszę (ctrl-d, aby wyjść): 256

*** Błąd zakresu SmallInt: 256 ***

#włączać class SmallInt ( przyjaciel istream& operator>(istream &is, SmallInt &s); przyjaciel ostream& operator<<(ostream &is, const SmallInt &s) { return os << s.value; } public: SmallInt(int i=0) : value(rangeCheck(i)){} int operator=(int i) { return(value = rangeCheck(i)); } operator int() { return value; } private: int rangeCheck(int); int value; };

Poniżej znajdują się definicje funkcji składowych poza treścią klasy:

Istream& operator>>(istream &is, SmallInt &si) ( int ix; is >> ix; si = ix; // SmallInt::operator=(int) return is; ) int SmallInt::rangeCheck(int i) ( /* jeśli przynajmniej jeden bit jest inny niż pierwsze osiem, * wtedy wartość jest zbyt duża; zgłoś i natychmiast wyjdź */ if (i & ~0377) ( cerr< <"\n*** Ошибка диапазона SmallInt: " << i << " ***" << endl; exit(-1); } return i; }

15.9.1. Konwertery

Konwerter to szczególny przypadek funkcji składowej klasy, która implementuje zdefiniowaną przez użytkownika konwersję obiektu na inny typ. Konwerter jest deklarowany w treści klasy przez określenie operatora słowa kluczowego, po którym następuje typ docelowy konwersji.

Nazwa następująca po słowie kluczowym nie musi być nazwą jednego z wbudowanych typów. Przedstawiona poniżej klasa Token definiuje kilka konwerterów. Jeden używa typedef tName do określenia nazwy typu, a drugi używa typu klasy SmallInt.

#include "SmallInt.h" typedef char *tName; class Token ( public: Token(char *, int); operator SmallInt() ( zwraca wartość; ) operator tName() ( zwraca nazwę; ) operator int() ( zwraca wartość; ) // inne publiczne składowe private: SmallInt val; znak*nazwa;);

Zauważ, że definicje konwerterów SmallInt i int są takie same. Konwerter Token::operator int() zwraca wartość elementu val. Ponieważ val jest typu SmallInt, funkcja SmallInt::operator int() jest niejawnie używana do konwersji val na int. Sam Token::operator int() jest niejawnie używany przez kompilator do konwersji obiektu typu Token na wartość typu int. Na przykład ten konwerter jest używany do niejawnego rzutowania rzeczywistych argumentów t1 i t2 typu Token na typ int parametru formalnego funkcji print():

#include "Token.h" void print(int i) ( cout< < "print(int) : " < < i < < endl; } Token t1("integer constant", 127); Token t2("friend", 255); int main() { print(t1); // t1.operator int() print(t2); // t2.operator int() return 0; }

Po skompilowaniu i uruchomieniu program wyświetli następujące wiersze:

Drukuj(int): 127 Drukuj(int): 255

Ogólny widok konwertera wygląda następująco:

typ operatora();

gdzie type może być typem wbudowanym, typem klasy lub nazwą typedef. Konwertery, w których type jest tablicą lub typem funkcji, są niedozwolone. Konwerter musi być funkcją członkowską. Jego deklaracja nie może określać ani typu zwracanego, ani listy parametrów:

Operator int(SmallInt &); // błąd: nie należy do klasy SmallInt ( public: int operator int(); // błąd: zwraca typ określonego operatora int(int = 0); // błąd: określona lista parametrów // ... );

Konwerter jest wywoływany w wyniku jawnej konwersji typu. Jeśli konwertowana wartość jest typu klasy, która ma konwerter, a typ tego konwertera jest określony w operacji rzutowania, nazywa się to:

#include "Token.h" Token tok("funkcja", 78); // notacja funkcjonalna: Token::operator SmallInt() nazywa się SmallInt tokVal = SmallInt(tok); // static_cast: Token::operator tName() nazywa się char *tokName = static_cast< char * >(tok);

Konwerter Token::operator tName() może mieć niepożądany efekt uboczny. Próba bezpośredniego dostępu do prywatnego elementu członkowskiego Token::name jest oflagowana przez kompilator jako błąd:

Char *tokName = tok.name; // błąd: Token::name jest członkiem prywatnym

Jednak nasz konwerter, umożliwiając użytkownikom bezpośrednią zmianę Token::name, robi dokładnie to, przed czym chcieliśmy chronić. Najprawdopodobniej to nie zadziała. Oto przykład, jak taka modyfikacja mogłaby się wydarzyć:

#include "Token.h" Token tok("funkcja", 78); char *tokName = tok; // poprawna: konwersja niejawna *tokname = "P"; // ale teraz nazwa członka ma Punction!

Zamierzamy zezwolić na dostęp tylko do odczytu do przekonwertowanego obiektu klasy Token. Dlatego konwerter musi zwrócić typ const char*:

Typedef const char *cchar; class Token ( public: operator cchar() ( zwracana nazwa; ) // ... ); // błąd: konwersja z char* na const char* niedozwolona char *pn = tok; const char *pn2 = tok; // prawo

Innym rozwiązaniem jest zastąpienie typu char* w definicji Tokena typem string ze standardowej biblioteki C++:

Class Token ( public: Token(string, int); operator SmallInt() ( zwraca val; ) operator string() ( zwraca nazwę; ) operator int() ( zwraca val; ) // inne publiczne składowe private: SmallInt val; string Nazwa; );

Semantyka konwertera Token::operator string() polega na zwróceniu kopii wartości (nie wskaźnika do wartości) ciągu reprezentującego nazwę tokena. Zapobiega to przypadkowej modyfikacji członka prywatnej nazwy klasy Token.

Czy typ docelowy musi dokładnie odpowiadać typowi konwertera? Na przykład, czy poniższy kod wywoła konwerter int() zdefiniowany w klasie Token?

extern void calc(podwójny); Token token("stała", 44); // Czy wywołano operator int()? Tak // stosowana jest konwersja standardowa int --> double calc(tok);

Jeśli typ docelowy (w tym przypadku double) nie jest dokładnie zgodny z typem konwertera (w naszym przypadku int), to konwerter nadal będzie wywoływany, pod warunkiem, że istnieje sekwencja standardowych konwersji, która prowadzi do celu typ z typu konwertera. (Te sekwencje są opisane w rozdziale 9.3.) Wywołanie funkcji calc() wywołuje Token::operator int() w celu przekonwertowania tok z typu Token na typ int. Następnie stosowana jest standardowa konwersja, aby rzutować wynik z int na double.

Po transformacji zdefiniowanej przez użytkownika dozwolone są tylko standardowe. Jeśli do osiągnięcia typu docelowego jest potrzebna inna konwersja zdefiniowana przez użytkownika, kompilator nie stosuje żadnych konwersji. Załóżmy, że nie ma zdefiniowanego operatora int() w klasie Token, wtedy następujące wywołanie będzie błędne:

extern void calc(int); token token("wskaźnik", 37); // jeśli Token::operator int() nie jest zdefiniowany, // to wywołanie spowoduje błąd kompilacji calc(tok);

Jeśli konwerter Token::operator int() nie jest zdefiniowany, rzutowanie tok na int wymagałoby wywołania dwóch konwerterów zdefiniowanych przez użytkownika. Po pierwsze, rzeczywisty argument tok musiałby zostać przekonwertowany z typu Token na typ SmallInt za pomocą konwertera

Token::operator SmallInt()

a następnie rzutuj wynik na typ int – również za pomocą niestandardowego konwertera

Token::operator int()

Wywołanie calc(tok) jest oznaczane przez kompilator jako błąd, ponieważ nie ma niejawnej konwersji z typu Token na typ int.

W przypadku braku logicznej zgodności między typem konwertera a typem klasy, przeznaczenie konwertera może nie być jasne dla czytelnika programu:

Class Date ( public: // spróbuj zgadnąć, który element jest zwracany! operator int(); private: int miesiąc, dzień, rok; );

Jaką wartość powinien zwrócić konwerter int() klasy Date? Bez względu na to, jak dobre są powody tej lub innej decyzji, czytelnik nie będzie wiedział, jak używać obiektów klasy Date, ponieważ nie ma oczywistej logicznej zgodności między nimi a liczbami całkowitymi. W takich przypadkach lepiej w ogóle nie definiować konwertera.

15.9.2. Konstruktor jako konwerter

Zestaw konstruktorów klas, które przyjmują pojedynczy parametr, taki jak SmallInt(int) klasy SmallInt, definiuje zestaw niejawnych konwersji na wartości SmallInt. Na przykład konstruktor SmallInt(int) konwertuje wartości int na wartości SmallInt.

Obliczenie pustki zewnętrznej (SmallInt); wew; // musisz przekonwertować i na wartość SmallInt // można to osiągnąć za pomocą SmallInt(int) calc(i); Gdy calc(i) jest wywoływana, i jest konwertowane na wartość SmallInt przy użyciu konstruktora SmallInt(int) wywoływanego przez kompilator w celu utworzenia tymczasowego obiektu żądanego typu. Kopia tego obiektu jest następnie przekazywana do funkcji calc() tak, jakby wywołanie funkcji miało postać: // Pseudokod w C++ // tworzenie tymczasowego obiektu typu SmallInt ( SmallInt temp = SmallInt(i); calc(temp); )

Nawiasy klamrowe w tym przykładzie oznaczają czas życia tego obiektu: jest on niszczony po zakończeniu działania funkcji.

Typ parametru konstruktora może być typem jakiejś klasy:

Class Number ( public: // tworzenie wartości typu Number z wartości typu SmallInt Number(const SmallInt &); // ... );

W takim przypadku wartość typu SmallInt może być używana wszędzie tam, gdzie dozwolona jest wartość typu Number:

extern void func(Numer); SmallInt si(87); int main() ( // dzwoniąc pod numer(const SmallInt &) func(si); // ... )

Jeśli konstruktor jest używany do wykonywania niejawnej konwersji, czy typ jego parametru musi dokładnie odpowiadać typowi wartości, która ma zostać przekonwertowana? Na przykład, czy poniższy kod wywołałby SmallInt(int), zdefiniowany w klasie SmallInt, aby rzutować dobj na typ SmallInt?

Obliczenie pustki zewnętrznej (SmallInt); podwójny dobj; // czy wywoływane jest SmallInt(int)? Tak // dobj jest konwertowane z double na int // przez standardową konwersję calc(dobj);

W razie potrzeby sekwencja standardowych konwersji jest stosowana do rzeczywistego argumentu przed wywołaniem konstruktora w celu wykonania konwersji zdefiniowanej przez użytkownika. Podczas wywoływania funkcji calc() używana jest standardowa konwersja dobj z double na int. Następnie wywoływana jest funkcja SmallInt(int) w celu rzutowania wyniku na SmallInt.

Kompilator niejawnie używa konstruktora z jednym parametrem, aby przekonwertować jego typ na typ klasy, do której należy konstruktor. Czasami jednak wygodniej jest wywoływać konstruktora Number(const SmallInt&) tylko w celu zainicjowania obiektu typu Number z wartością typu SmallInt i nigdy do wykonywania niejawnych konwersji. Aby uniknąć takiego użycia konstruktora, zadeklarujmy to jawnie:

Numer klasy ( public: // nigdy nie używaj jawnie Number(const SmallInt &); // ... );

Kompilator nigdy nie używa jawnych konstruktorów do wykonywania niejawnych konwersji typów:

extern void func(Numer); SmallInt si(87); int main() ( // błąd: nie ma niejawnej konwersji z SmallInt na Number func(si); // ... )

Jednak taki konstruktor nadal może być używany do konwersji typu, jeśli jest to wyraźnie wymagane w postaci operatora rzutowania:

SmallInt si(87); int main() ( // błąd: nie ma niejawnej konwersji z SmallInt na Number func(si); func(Number(si)); // poprawnie: rzut func(static_cast< Number >(si)); // poprawna: rzut )

15.10. Wybór transformacji A

Konwersja zdefiniowana przez użytkownika jest implementowana jako konwerter lub konstruktor. Jak już wspomniano, po konwersji wykonanej przez konwerter można użyć konwersji standardowej do rzutowania zwracanej wartości na typ docelowy. Transformacja wykonywana przez konstruktor może być również poprzedzona standardową konwersją w celu rzutowania typu argumentu na typ parametru formalnego konstruktora.

Sekwencja transformacji zdefiniowanych przez użytkownika jest kombinacją określony przez użytkownika oraz standardową konwersję potrzebną do rzutowania wartości na typ docelowy. Taka sekwencja wygląda tak:

Sekwencja przekształceń standardowych ->

Transformacja zdefiniowana przez użytkownika ->

Sekwencja przekształceń standardowych

gdzie konwersja zdefiniowana przez użytkownika jest implementowana przez konwerter lub konstruktor.

Możliwe, że istnieją dwie różne sekwencje konwersji zdefiniowanych przez użytkownika, aby przekształcić wartość źródłową w typ docelowy, a następnie kompilator musi wybrać najlepszą z nich. Zobaczmy, jak to się robi.

Dozwolone jest zdefiniowanie wielu konwerterów w klasie. Na przykład nasza klasa Number ma dwa: operator int() i operator float(), oba mogą konwertować obiekt Number na wartość zmiennoprzecinkową. Oczywiście możesz użyć konwertera Token::operator float() do bezpośredniej transformacji. Ale Token::operator int() również działa, ponieważ jego wynik jest typu int i dlatego można go przekonwertować na float przy użyciu standardowej konwersji. Czy transformacja jest niejednoznaczna, jeśli istnieje wiele takich sekwencji? A może jeden z nich jest lepszy od pozostałych?

Numer klasy ( public: operator float(); operator int(); // ... ); numer numer; floatff = liczba; // który konwerter? platforma()

W takich przypadkach wybór najlepszej kolejności przekształceń zdefiniowanych przez użytkownika opiera się na analizie sekwencji przekształceń zastosowanej po konwerterze. W poprzednim przykładzie można zastosować następujące dwie sekwencje:

  1. operator float() -> dokładne dopasowanie
  2. operator int() -> konwersja standardowa

Jak omówiono w sekcji 9.3, dokładne dopasowanie jest lepsze niż standardowe przekształcenie. Dlatego pierwsza sekwencja jest lepsza od drugiej, co oznacza, że ​​wybrano konwerter Token::operator float().

Może się zdarzyć, że do konwersji wartości na typ docelowy mają zastosowanie dwa różne konstruktory. W tym przypadku analizowana jest sekwencja standardowych przekształceń poprzedzających wywołanie konstruktora:

Class SmallInt ( public: SmallInt(int ival) : value(ival) ( ) SmallInt(double dval) : value(static_cast< int >(dwal)); ( ) ); extern void manip(const SmallInt &); int main() ( double dobj; manip(dobj); // poprawna: SmallInt(double) )

Tutaj klasa SmallInt definiuje dwa konstruktory, SmallInt(int) i SmallInt(double), które mogą być użyte do zmiany wartości double na obiekt SmallInt: SmallInt(double) przekształca double bezpośrednio w SmallInt, podczas gdy SmallInt(int) działa w wyniku standardowej konwersji double na int. Tak więc istnieją dwie sekwencje transformacji zdefiniowanych przez użytkownika:

  1. dokładne dopasowanie -> SmallInt(podwójne)
  2. konwersja standardowa -> SmallInt(int)

Ponieważ dokładne dopasowanie jest lepsze niż standardowa konwersja, wybierany jest konstruktor SmallInt(double).

Nie zawsze można zdecydować, która sekwencja jest lepsza. Może się zdarzyć, że wszystkie są jednakowo dobre, wtedy mówimy, że transformacja jest niejednoznaczna. W takim przypadku kompilator nie stosuje żadnych przekształceń niejawnych. Na przykład, jeśli klasa Number ma dwa konwertery:

Numer klasy ( public: operator float(); operator int(); // ... );

wtedy nie jest możliwe niejawne przekonwertowanie obiektu typu Number na typ long. Poniższa instrukcja powoduje błąd kompilacji, ponieważ sekwencja konwersji zdefiniowanych przez użytkownika jest niejednoznaczna:

// błąd: można użyć zarówno float() jak i int() long lval = num;

Do przekształcenia num w wartość typu long stosuje się dwie takie sekwencje:

  1. operator float() -> konwersja standardowa
  2. operator int() -> konwersja standardowa

Ponieważ w obu przypadkach po użyciu konwertera następuje zastosowanie standardowej konwersji, obie sekwencje są równie dobre i kompilator nie może wybrać żadnej z nich.

Przy jawnym rzutowaniu typów programista jest w stanie określić pożądaną zmianę:

// poprawna: jawne rzutowanie long lval = static_cast (liczba);

W wyniku tej specyfikacji wybierany jest konwerter Token::operator int(), po którym następuje standardowa konwersja na long.

Niejednoznaczność w wyborze sekwencji przekształceń może również powstać, gdy dwie klasy definiują przekształcenia w siebie. Na przykład:

Klasa SmallInt ( public: SmallInt(const Number &); // ... ); klasa Numer ( public: operator SmallInt(); // ... ); extern void compute(SmallInt); numer zewnętrzny numer; obliczyć(liczba); // błąd: możliwe dwie konwersje

Argument num jest konwertowany na SmallInt przez dwa różne sposoby: za pomocą konstruktora SmallInt::SmallInt(const Number&) lub konwertera Number::operator SmallInt(). Ponieważ obie zmiany są równie dobre, wywołanie jest uważane za błąd.

Aby rozwiązać niejednoznaczność, programista może jawnie wywołać konwerter klasy Number:

// poprawna: jawne wywołanie ujednoznacznia compute(num.operator SmallInt());

Jednak jawne rzutowania nie powinny być używane do rozwiązywania niejednoznaczności, ponieważ zarówno konwerter, jak i konstruktor są brane pod uwagę przy wyborze konwersji odpowiednich do rzutowania typu:

Oblicz (SmallInt (liczba)); // błąd: nadal niejednoznaczny

Jak widać, obecność duża liczba takie konwertery i konstruktory są niebezpieczne, więc ich. należy stosować ostrożnie. Możesz ograniczyć użycie konstruktorów podczas wykonywania niejawnych konwersji (a tym samym zmniejszyć ryzyko nieoczekiwanych efektów), czyniąc je jawnymi.

15.10.1. Powrót do rozdzielczości przeciążenia funkcji

Rozdział 9 szczegółowo opisuje, jak rozwiązać przeciążone wywołanie funkcji. Jeśli rzeczywiste argumenty, gdy są wywoływane, są typu klasy, wskaźnikiem do typu klasy lub wskaźnikiem do członków klasy, to więcej funkcji rywalizuje o potencjalnych kandydatów. Dlatego obecność takich argumentów wpływa na pierwszy krok procedury rozwiązywania przeciążeń - wybór zestawu funkcji kandydujących.

W trzecim kroku tej procedury wybierane jest najlepsze dopasowanie. W tym przypadku rangowane są konwersje typów rzeczywistych argumentów na typy parametrów formalnych funkcji. Jeżeli argumenty i parametry są typu class, to zbiór możliwych konwersji powinien również zawierać sekwencje konwersji zdefiniowanych przez użytkownika, również poddając je rankingowi.

W tej sekcji przyjrzymy się bliżej, jak rzeczywiste argumenty i parametry formalnego typu klasy wpływają na wybór funkcji kandydujących oraz jak sekwencje konwersji zdefiniowanych przez użytkownika wpływają na wybór najlepiej ustalonej funkcji.

15.10.2. Funkcje kandydata

Funkcja kandydująca to funkcja o tej samej nazwie, co wywołana. Załóżmy, że mamy takie połączenie:

SmallInt si(15); dodaj(si, 566);

Funkcja kandydata musi mieć nazwę add. Które deklaracje add() są brane pod uwagę? Te, które są widoczne w punkcie wezwania.

Na przykład obie funkcje add() zadeklarowane w zasięgu globalnym byłyby kandydatami do następującego wywołania:

const matrix& add(const matrix &, int); podwójny dodaj (podwójny, podwójny); int main() ( SmallInt si(15); add(si, 566); // ... )

Uwzględnienie funkcji, których deklaracje są widoczne w punkcie wywołania, nie ogranicza się do wywołań z argumentami typu klasy. Jednak dla nich poszukiwanie deklaracji odbywa się w jeszcze dwóch zakresach:

  • jeśli rzeczywisty argument jest obiektem typu klasy, wskaźnikiem lub odwołaniem do typu klasy lub wskaźnikiem do elementu klasy, a typ jest zadeklarowany w przestrzeni nazw zdefiniowanej przez użytkownika, wówczas funkcje zadeklarowane w tej przestrzeni i mające te same nazwy są dodawane do zestawu funkcji kandydujących i wywoływane:
przestrzeń nazw NS ( class SmallInt ( /* ... */ ); class String ( /* ... */ ); String add(const String &, const String &); ) int main() ( // si jest type class SmallInt: // klasa jest zadeklarowana w przestrzeni nazw NS NS::SmallInt si(15); add(si, 566); // NS::add() jest funkcją kandydującą return 0; )

Argument si jest typu SmallInt, tj. typ klasy zadeklarowany w przestrzeni nazw NS. Dlatego add(const String &, const String &) zadeklarowane w tej przestrzeni nazw jest dodawane do zestawu funkcji kandydujących;

  • jeśli faktycznym argumentem jest obiekt typu klasy, wskaźnik lub odwołanie do klasy lub wskaźnik do elementu klasy, a klasa ma znajomych o tej samej nazwie co wywoływana funkcja, to są one dodawane do zbioru kandydujących funkcji:
  • przestrzeń nazw NS ( class SmallInt ( friend SmallInt add(SmallInt, int) ( /* ... */ ) ); ) int main() ( NS::SmallInt si(15); add(si, 566); // funkcja - friend add() - kandydat zwraca 0; )

    Argument funkcji si jest typu SmallInt. Funkcja zaprzyjaźniona klasy SmallInt add(SmallInt, int) jest członkiem przestrzeni nazw NS, chociaż nie jest bezpośrednio zadeklarowana w tej przestrzeni. Normalne wyszukiwanie w NS nie znajdzie funkcji przyjaciela. Jednak gdy funkcja add() zostanie wywołana z argumentem typu klasy SmallInt, znajomi klasy zadeklarowani na jej liście członków są również brani pod uwagę i dodawani do zbioru kandydatów.

    Tak więc, jeśli lista rzeczywistych argumentów funkcji zawiera obiekt, wskaźnik lub odwołanie do klasy oraz wskaźniki do elementów klasy, to zbiór funkcji kandydujących składa się ze zbioru funkcji widocznych w punkcie wywołania lub zadeklarowanych w tej samej przestrzeni nazw, w której jest zdefiniowany typ klasy, lub zadeklarowani przyjaciele tej klasy.

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

    Przestrzeń nazw NS ( class SmallInt ( friend SmallInt add(SmallInt, int) ( /* ... */ ) ); class String ( /* ... */ ); String add(const String &, const String &); ) const matrix& add(const matrix &, int); podwójny dodaj (podwójny, podwójny); int main() ( // si jest typu class SmallInt: // klasa jest zadeklarowana w przestrzeni nazw NS NS::SmallInt si(15); add(si, 566); // funkcja zaprzyjaźniona nazywa się return 0; )

    Oto kandydaci:

    • funkcje globalne:
    const matrix& add(const matrix &, int) double add(double, double)
  • funkcja z przestrzeni nazw:
  • NS::add(const String &, const String &)
  • funkcja przyjaciela:
  • NS::add(SmallInt, int)

    Rozdzielczość przeciążenia wybiera zaprzyjaźnioną funkcję klasy SmallInt NS::add(SmallInt, int) jako najlepsze dopasowanie: oba rzeczywiste argumenty dokładnie pasują do podanych parametrów formalnych.

    Oczywiście wywoływana funkcja może mieć wiele argumentów typu klasy, wskaźnik lub odwołanie do klasy lub wskaźnik do elementu klasy. Dla każdego z tych argumentów dozwolone są różne typy klas. Wyszukiwanie dla nich funkcji kandydujących odbywa się w przestrzeni nazw, w której zdefiniowana jest klasa, oraz wśród funkcji zaprzyjaźnionych klasy. Dlatego powstały zbiór kandydatów do wywołania funkcji z takimi argumentami zawiera funkcje z różnych przestrzeni nazw i funkcje zaprzyjaźnione zadeklarowane w różnych klasach.

    15.10.3. Funkcje kandydujące do wywołania funkcji w zakresie klasy

    Podczas wywoływania funkcji postaci

    występuje w zakresie klasy (np. wewnątrz funkcji składowej), to pierwsza część zbioru kandydatów opisanego w poprzednim podrozdziale (tj. zbiór zawierający deklaracje funkcji widocznych w punkcie wywołania) może zawierać więcej niż tylko funkcje składowe klasy. Rozpoznawanie nazw służy do konstruowania takiego zestawu. (Temat ten został szczegółowo omówiony w rozdziałach 13.9 - 13.12.)

    Rozważ przykład:

    Przestrzeń nazw NS ( struct myClass ( void k(int); static void k(char*); void mf(); ); int k(double); ); nieważne h(znak); void NS::myClass::mf() ( h("a"); // wywołanie globalnego h(char) k(4); // wywołanie myClass::k(int) )

    Jak zauważono w sekcji 13.11, kwalifikatory NS::myClass:: są wyszukiwane w odwrotnej kolejności: najpierw widoczna deklaracja nazwy użytej w definicji funkcji składowej mf() jest wyszukiwana w klasie myClass, a następnie w NS przestrzeń nazw. Rozważ pierwszą rozmowę:

    Podczas rozwiązywania nazwy h() w definicji funkcji składowej mf() najpierw sprawdzane są funkcje składowe myClass. Ponieważ w zakresie tej klasy nie ma funkcji składowej o tej nazwie, wyszukiwanie jest kontynuowane w przestrzeni nazw NS. Funkcja h() też nie istnieje, więc przechodzimy do zasięgu globalnego. Wynikiem jest funkcja globalna h(char), jedyna funkcja kandydująca widoczna w momencie wywołania.

    Gdy tylko zostanie znaleziona odpowiednia reklama, wyszukiwanie się zatrzymuje. Dlatego zestaw zawiera tylko te funkcje, których deklaracje znajdują się w zakresach, w których rozwiązywanie nazw powiodło się. Widać to na przykładzie konstruowania zbioru kandydatów do powołania

    Najpierw wyszukiwanie odbywa się w zakresie klasy myClass. To znalazło dwie funkcje składowe k(int) i k(char*). Ponieważ zestaw kandydujący zawiera tylko funkcje zadeklarowane w zakresie, w którym rozwiązanie powiodło się, przestrzeń nazw NS nie jest wyszukiwana, a funkcja k(double) nie jest zawarta w tym zestawie.

    Jeśli wywołanie okaże się niejednoznaczne, ponieważ w zestawie nie ma najlepiej dopasowanej funkcji, kompilator generuje komunikat o błędzie. Kandydaci, którzy lepiej pasują do rzeczywistych argumentów, nie są wyszukiwani w otaczających zakresach.

    15.10.4. Rankingowe sekwencje transformacji zdefiniowanych przez użytkownika

    Rzeczywisty argument funkcji można niejawnie rzutować na typ parametru formalnego przy użyciu serii konwersji zdefiniowanych przez użytkownika. Jak to wpływa na rozwiązanie problemu przeciążenia? Na przykład, jeśli nastąpi następne wywołanie funkcji calc(), jaka funkcja zostanie wywołana?

    Klasa SmallInt ( public: SmallInt(int); ); extern void calc(podwójny); extern void calc(SmallInt); int ival; int main() ( calc(ival); // który calc() jest wywoływany? )

    Wybierana jest funkcja, której parametry formalne najlepiej pasują do typów rzeczywistych argumentów. Nazywa się to najlepszym dopasowaniem lub najlepszą cechą stojącą. Aby wybrać taką funkcję, klasyfikowane są niejawne konwersje zastosowane do rzeczywistych argumentów. Najlepszy zachowany to taki, dla którego zmiany zastosowane do argumentów nie są gorsze niż dla każdej innej funkcji, która przetrwała, i dla przynajmniej jednego argumentu są lepsze niż dla wszystkich innych funkcji.

    Sekwencja standardowych konwersji jest zawsze lepsza niż sekwencja konwersji zdefiniowanych przez użytkownika. Tak więc, podczas wywoływania calc() z powyższego przykładu, obie funkcje calc() są dobrze ustalone. calc(double) przetrwało, ponieważ istnieje standardowa konwersja z rzeczywistego argumentu int na typ parametru formalnego double, a calc(SmallInt), ponieważ istnieje zdefiniowana przez użytkownika konwersja z int na SmallInt, która używa konstruktora SmallInt(int). Dlatego najlepszą funkcją stojącą jest calc(double).

    Jak porównuje się dwie sekwencje transformacji zdefiniowanych przez użytkownika? Jeśli używają różnych konwerterów lub różnych konstruktorów, to obie takie sekwencje są uważane za równie dobre:

    Numer klasy ( public: operator SmallInt(); operator int(); // ... ); extern void calc(int); extern void calc(SmallInt); numer zewnętrzny numer; oblicz(liczba); // błąd: niejednoznaczność

    Zarówno calc(int) jak i calc(SmallInt) przetrwają; po pierwsze dlatego, że konwerter Number::operator int() konwertuje rzeczywisty argument typu Number na formalny parametr typu int, a po drugie, ponieważ konwerter Number::operator SmallInt() konwertuje rzeczywisty argument typu Number na formalny typ typu SmallInt parametr. Ponieważ sekwencje konwersji zdefiniowanych przez użytkownika zawsze mają tę samą rangę, kompilator nie może zdecydować, która z nich jest lepsza. W związku z tym to wywołanie funkcji jest niejednoznaczne i powoduje błąd kompilacji.

    Istnieje sposób na rozwiązanie niejednoznaczności poprzez jawne określenie konwersji:

    // jawne rzutowanie rozróżnia calc(static_cast< int >(liczba));

    Jawne rzutowanie powoduje, że kompilator konwertuje argument num na int za pomocą konwertera Number::operator int(). Rzeczywisty argument będzie wtedy typu int, co dokładnie odpowiada funkcji calc(int), która została wybrana jako najlepsza.

    Załóżmy, że konwerter Number::operator int() nie jest zdefiniowany w klasie Number. Czy będzie wtedy wyzwanie?

    // tylko Number::operator SmallInt() jest zdefiniowany calc(num); // nadal niejednoznaczny?

    nadal niejednoznaczne? Przypomnijmy, że SmallInt ma również konwerter, który może przekonwertować wartość SmallInt na int.

    Klasa SmallInt ( public: operator int(); // ... );

    Możemy założyć, że funkcja calc() jest wywoływana przez konwersję rzeczywistego argumentu num z typu Number na typ SmallInt za pomocą konwertera Number::operator SmallInt(), a następnie rzutowanie wyniku na int za pomocą SmallInt::operator SmallInt() . Jednak tak nie jest. Przypomnij sobie, że sekwencja przekształceń zdefiniowanych przez użytkownika może obejmować kilka przekształceń standardowych, ale tylko jedną niestandardową. Jeśli konwerter Number::operator int() nie jest zdefiniowany, funkcja calc(int) nie jest uważana za stabilną, ponieważ nie ma niejawnej konwersji z typu rzeczywistego argumentu num na typ parametru formalnego int.

    Dlatego w przypadku braku konwertera Number::operator int() jedyną pozostałą funkcją będzie calc(SmallInt), na rzecz której wywołanie jest dozwolone.

    Jeżeli dwie sekwencje konwersji zdefiniowanych przez użytkownika korzystają z tego samego konwertera, to wybór najlepszego z nich zależy od sekwencji konwersji standardowych wykonywanych po jego wywołaniu:

    Klasa SmallInt ( public: operator int(); // ... ); void manip(int); void manip(char); SmallInt si(68); main() ( manip(si); // wywołanie manip(int) )

    Zarówno manip(int) jak i manip(char) są ustalonymi funkcjami; po pierwsze, ponieważ konwerter SmallInt::operator int() konwertuje rzeczywisty argument typu SmallInt na typ parametru formalnego int, a po drugie, ponieważ ten sam konwerter konwertuje SmallInt na int, po czym wynik jest rzutowany na char przy użyciu standardowej konwersji. Sekwencje transformacji zdefiniowanych przez użytkownika wyglądają tak:

    Manip(int) : operator int()->dokładne dopasowanie manip(int) : operator int()->standardowa konwersja

    Ponieważ ten sam konwerter jest używany w obu sekwencjach, ranga sekwencji standardowych transformacji jest analizowana w celu określenia najlepszej z nich. Ponieważ dokładne dopasowanie jest lepsze niż konwersja, najlepiej ustaloną funkcją jest manip(int).

    Podkreślamy, że takie kryterium wyboru jest akceptowane tylko wtedy, gdy ten sam konwerter jest używany w obu sekwencjach transformacji zdefiniowanych przez użytkownika. Tutaj nasz przykład różni się od tych na końcu sekcji 15.9, gdzie pokazaliśmy, jak kompilator wybiera konwersję zdefiniowaną przez użytkownika z pewnej wartości na dany typ docelowy: typy źródłowe i docelowe zostały naprawione, a kompilator musiał wybrać między różnymi konwersjami zdefiniowanymi przez użytkownika z jednego typu na inny. Rozważamy tutaj dwie różne funkcje z różnymi typami parametrów formalnych, a typy docelowe są różne. Jeśli dla dwojga różne rodzaje parametry wymagają różnych konwersji zdefiniowanych przez użytkownika, możliwe jest tylko preferowanie jednego typu względem drugiego, jeśli ten sam konwerter jest używany w obu sekwencjach. Jeśli tak nie jest, standardowe konwersje po zastosowaniu konwertera są oceniane w celu wybrania najlepszego typu docelowego. Na przykład:

    Class SmallInt ( public: operator int(); operator float(); // ... ); nieważne obliczenie (liczba zmiennoprzecinkowa); void obliczyć(char); SmallInt si(68); main() ( obliczyć(si); // niejednoznaczność )

    Zarówno compute(float), jak i compute(int) są ustalonymi funkcjami. compute(float) jest dlatego, że konwerter SmallInt::operator float() konwertuje argument typu SmallInt na typ parametru float, a compute(char), ponieważ SmallInt::operator int() konwertuje argument typu SmallInt na typ int, po przy czym wynik jest standardowo rzutowany na typ char. Tak więc istnieją sekwencje:

    Compute(float) : operator float()->dokładne dopasowanie compute(char) : operator char()->standardowa konwersja

    Ponieważ używają różnych konwerterów, nie można określić, która funkcja ma parametry formalne, które lepiej pasują do wywołania. Aby wybrać najlepszą z nich, nie jest używana ranga sekwencji standardowych przekształceń. Wywołanie jest oznaczone przez kompilator jako niejednoznaczne.

    Ćwiczenie 15.12

    W klasach biblioteki standardowej C++ nie ma definicji konwerterów, a większość konstruktorów, które przyjmują jeden parametr, jest zadeklarowana jawnie. Jednak zdefiniowano wiele przeciążonych operatorów. Jak myślisz, dlaczego ta decyzja została podjęta podczas projektowania?

    Ćwiczenie 15.13

    Dlaczego przeciążony operator wejściowy dla klasy SmallInt zdefiniowany na początku tej sekcji nie jest zaimplementowany w następujący sposób:

    Istream& operator>>(istream &is, SmallInt &si) ( return (is >> is.value); )

    Ćwiczenie 15.14

    Podaj możliwe sekwencje konwersji zdefiniowanych przez użytkownika dla następujących inicjacji. Jaki będzie wynik każdej inicjalizacji?

    Class LongDouble ( operator double(); operator float(); ); zewnętrzny LongDouble ldObj; (a) int ex1 = ldObj; (b) float ex2 = ldObj;

    Ćwiczenie 15.15

    Nazwij trzy zestawy funkcji kandydujących, które są brane pod uwagę podczas rozwiązywania przeciążenia funkcji, gdy co najmniej jeden z jej argumentów jest typu klasy.

    Ćwiczenie 15.16

    Która z funkcji calc() jest w tym przypadku wybrana jako najlepiej sprawdzająca się? Pokaż sekwencję przekształceń wymaganych do wywołania każdej funkcji i wyjaśnij, dlaczego jedna jest lepsza od drugiej.

    Class LongDouble( public: LongDouble(double); // ... ); extern void calc(int); extern void calc(LongDouble); podwójny dval; int main() ( calc(dval); // jaka funkcja? )

    15.11. Rozdzielczość przeciążenia i funkcje składowe A

    Funkcje składowe mogą być również przeciążone, w takim przypadku również procedura rozpoznawania przeciążenia jest stosowana w celu wybrania najlepszego, który stoi. To rozwiązanie jest bardzo podobne do procedury dla normalnych funkcji i składa się z tych samych trzech kroków:

    1. Wybór funkcji kandydujących.
    2. Wybór ustalonych funkcji.

    Istnieją jednak niewielkie różnice w algorytmach generowania zbioru kandydatów i wybierania stabilnych funkcji składowych. Rozważymy te różnice w tej sekcji.

    15.11.1. Przeciążone deklaracje funkcji członkowskich

    Funkcje składowe klasy mogą być przeciążone:

    Class mojaKlasa ( public: void f(double); char f(char, char); // przeciąża mojaKlasa::f(double) // ... );

    Podobnie jak w przypadku funkcji zadeklarowanych w przestrzeni nazw, funkcje składowe mogą mieć taką samą nazwę, pod warunkiem, że ich listy parametrów różnią się liczbą parametrów lub ich typami. Jeśli deklaracje dwóch funkcji składowych różnią się tylko typem zwracanym, to druga deklaracja jest uważana za błąd kompilacji:

    Class myClass ( public: void mf(); double mf(); // błąd: nie można przeciążyć // ... );

    W przeciwieństwie do funkcji w przestrzeniach nazw funkcje członkowskie muszą być deklarowane tylko raz. Nawet jeśli zwracany typ i listy parametrów dwóch funkcji składowych są takie same, kompilator interpretuje drugą deklarację jako nieprawidłową ponowną deklarację:

    Class mojaKlasa ( public: void mf(); void mf(); // błąd: ponowna deklaracja // ... );

    Wszystkie przeciążone funkcje muszą być zadeklarowane w tym samym zakresie. W związku z tym funkcje członkowskie nigdy nie przeciążają funkcji zadeklarowanych w przestrzeni nazw. Ponadto, ponieważ każda klasa ma swój własny zakres, funkcje należące do różnych klas nie przeciążają się nawzajem.

    Zestaw przeciążonych funkcji składowych może zawierać zarówno funkcje statyczne, jak i niestatyczne:

    Klasa mojaKlasa ( public: void mcf(double); static void mcf(int*); // przeciąża myClass::mcf(double) // ... );

    Która z funkcji składowych zostanie wywołana — statyczna czy niestatyczna — zależy od wyników rozpoznawania przeciążenia. Proces rozwiązywania w sytuacji, gdy przetrwały zarówno statyczne, jak i niestatyczne elementy członkowskie, został szczegółowo omówiony w następnej sekcji.

    15.11.2. Funkcje kandydata

    Rozważ dwa rodzaje wywołań funkcji składowych:

    Mc.mf(arg); pmc->mf(arg);

    gdzie mc jest wyrażeniem typu mojaKlasa, a pmc jest wyrażeniem typu "wskaźnik do typu mojaKlasa". Zbiór kandydatów dla obu wywołań składa się z funkcji znalezionych w zakresie myClass podczas szukania deklaracji mf().

    Podobnie dla wywołania funkcji postaci

    MojaKlasa::mf(arg);

    zbiór kandydatów składa się również z funkcji znalezionych w zakresie klasy myClass podczas szukania deklaracji mf(). Na przykład:

    Class mojaKlasa ( public: void mf(double); void mf(char, char = "\n"); static void mf(int*); // ... ); int main() ( mojaKlasa mc; int iobj; mc.mf(iobj); )

    Kandydaci do wywołania funkcji w main() to wszystkie trzy funkcje składowe mf() zadeklarowane w myClass:

    Puste mf (podwójne); void mf(char, char = "\n"); statyczna pustka mf(int*);

    Jeśli w myClass nie zadeklarowano żadnych funkcji składowych o nazwie mf(), zbiór kandydatów byłby pusty. (Właściwie, funkcje z klas bazowych również byłyby brane pod uwagę. Omówimy, w jaki sposób mieszczą się one w tym zestawie w sekcji 19.3). Jeśli nie ma kandydatów do wywołania funkcji, kompilator wyświetla komunikat o błędzie.

    15.11.3. Ugruntowane cechy

    Dobrze ugruntowana funkcja to funkcja ze zbioru kandydatów, które można wywołać z podanymi rzeczywistymi argumentami. Aby przetrwał, muszą istnieć niejawne konwersje między typami rzeczywistych argumentów a parametrami formalnymi. Na przykład: class mojaKlasa ( public: void mf(double); void mf(char, char = "\n"); static void mf(int*); // ... ); int main() ( myClass mc; int iobj; mc.mf(iobj); // która funkcja członkowska mf()? Niejednoznaczne )

    W tym fragmencie znajdują się dwie dobrze znane funkcje do wywoływania mf() z main():

    Puste mf (podwójne); void mf(char, char = "\n");

    • mf(double) przetrwało, ponieważ ma tylko jeden parametr i istnieje standardowa konwersja argumentu int iobj na parametr double;
    • mf(char,char) przetrwało, ponieważ istnieje wartość domyślna drugiego parametru i istnieje standardowa konwersja argumentu int iobj na typ char pierwszego parametru formalnego.

    Wybierając najlepsze z ugruntowanych funkcji konwersji typów stosowanych do każdego rzeczywistego argumentu, są one klasyfikowane. Najlepsza jest taka, dla której wszystkie użyte transformacje nie są gorsze niż dla każdej innej dobrze ustalonej funkcji, a przynajmniej dla jednego argumentu taka transformacja jest lepsza niż dla wszystkich innych funkcji.

    W poprzednim przykładzie każda z dwóch ustanowionych funkcji używa standardowej konwersji do rzutowania typu rzeczywistego argumentu na typ parametru formalnego. Wywołanie jest uważane za niejednoznaczne, ponieważ obie funkcje członkowskie rozwiązują je równie dobrze.

    Niezależnie od typu wywołania funkcji, zarówno statyczne, jak i niestatyczne składowe mogą być zawarte w zbiorze zachowanych składowych:

    Class mojaKlasa ( public: static void mf(int); char mf(char); ); int main() ( char cobj; mojaKlasa::mf(cobj); // która funkcja składowa? )

    W tym przypadku funkcja składowa mf() jest wywoływana z nazwą klasy i operatorem rozpoznawania zakresu myClass::mf(). Jednak nie jest określony ani obiekt (z operatorem „kropka”), ani wskaźnik do obiektu (z operatorem „strzałka”). Mimo to niestatyczna funkcja składowa mf(char) jest nadal zawarta w ocalałym zbiorze wraz ze statycznym składową mf(int).

    Następnie proces rozpoznawania przeciążenia jest kontynuowany: na podstawie rankingu konwersji typów zastosowanych do rzeczywistych argumentów, aby wybrać najlepszą stałą funkcję. Argument cobj typu char dokładnie odpowiada formalnemu parametrowi mf(char) i może być rozszerzony do typu formalnego parametru mf(int). Ponieważ ranga dokładnego dopasowania jest wyższa, wybierana jest funkcja mf(char).

    Jednak ta funkcja składowa nie jest statyczna i dlatego jest wywoływana tylko przez obiekt lub wskaźnik do obiektu klasy myClass przy użyciu jednego z akcesorów. W takiej sytuacji, jeśli obiekt nie jest określony, a co za tym idzie wywołanie funkcji jest niemożliwe (tak jak w naszym przypadku), kompilator uzna to za błąd.

    Inną cechą funkcji składowych, którą należy wziąć pod uwagę podczas tworzenia zestawu ustalonych funkcji, jest obecność stałych lub zmiennych specyfikatorów na niestatycznych składowych. (Omówiono je w rozdziale 13.3) Jak wpływają na proces rozwiązywania problemów z przeciążeniem? Niech klasa myClass będzie miała następujące funkcje składowe:

    Class mojaKlasa ( public: static void mf(int*); void mf(double); void mf(int) const; // ... );

    Następnie zarówno statyczna funkcja składowa mf(int*), stała funkcja mf(int), jak i funkcja niestała mf(double) są zawarte w zestawie kandydującym dla wywołania pokazanego poniżej. Ale który z nich znajdzie się w zestawie ocalałych?

    int main() ( const mojaKlasa mc; double dobj; mc.mf(dobj); // która funkcja składowa mf()? )

    Kiedy badamy transformacje, które mają być zastosowane do rzeczywistych argumentów, stwierdzamy, że funkcje mf(double) i mf(int) przetrwały. Typ double rzeczywistego argumentu dobj dokładnie odpowiada typowi parametru formalnego mf(double) i może być rzutowany na typ parametru mf(int) przy użyciu standardowej konwersji.

    Jeśli w wywołaniu funkcji składowej są używane operatory dostępu kropka lub strzałka, typ obiektu lub wskaźnika, dla którego wywoływana jest funkcja, jest brany pod uwagę podczas wybierania funkcji do zbioru stagnacji.

    mc jest obiektem const, na którym można wywoływać tylko niestatyczne funkcje składowe const. Dlatego niestała funkcja składowa mf(double) jest wykluczona ze zbioru pozostałych, a pozostaje w niej jedyna funkcja mf(int), która jest wywoływana.

    Co się stanie, jeśli const obiekt jest używany do wywołania statycznej funkcji składowej? W końcu dla takiej funkcji nie można ustawić specyfikatora const lub volatile, więc czy można ją wywołać przez obiekt const?

    Class mojaKlasa ( public: static void mf(int); char mf(char); ); int main() ( const myClass mc; int iobj; mc.mf(iobj); // czy można wywołać statyczną funkcję składową? )

    Statyczne funkcje składowe są wspólne dla wszystkich obiektów tej samej klasy. Mają bezpośredni dostęp tylko do statycznych członków klasy. W związku z tym niestatyczne elementy obiektu stałego mc nie są dostępne dla statycznego mf(int). Z tego powodu dozwolone jest wywołanie statycznej funkcji składowej na obiekcie const przy użyciu operatorów kropki lub strzałki.

    W ten sposób statyczne funkcje składowe nie są wykluczone ze zbioru zachowanych funkcji, nawet jeśli istnieją specyfikatory const lub volatile na obiekcie, na którym są wywoływane. Statyczne funkcje składowe są traktowane jako odpowiadające dowolnemu obiektowi lub wskaźnikowi do obiektu ich klasy.

    W powyższym przykładzie mc jest obiektem stałym, więc funkcja składowa mf(char) jest wykluczona ze zbioru pozostałych. Ale funkcja członkowska mf(int) pozostaje w nim, ponieważ jest statyczna. Ponieważ jest to jedyna stabilna funkcja, okazuje się, że jest najlepsza.

    15.12. Rozdzielczość przeciążenia i operatory A

    Klasy mogą deklarować przeciążone operatory i konwertery. Załóżmy, że podczas inicjalizacji napotkano operator dodawania:

    SomeClass sc; int iobj = sc + 3;

    W jaki sposób kompilator decyduje, czy wywołać przeciążony operator w SomeClass, czy przekonwertować operand sc na typ wbudowany, a następnie użyć wbudowanego operatora?

    Odpowiedź zależy od wielu przeciążonych operatorów i konwerterów zdefiniowanych w SomeClass. Po wybraniu operatora do wykonania dodawania stosowany jest proces rozpoznawania przeciążenia funkcji. W ta sekcja opiszemy, jak ten proces pozwala wybrać żądany operator, gdy operandy są obiektami typu class.

    Rozdzielczość przeciążenia wykorzystuje tę samą trzyetapową procedurę opisaną w rozdziale 9.2:

    • Wybór funkcji kandydujących.
    • Wybór ustalonych funkcji.
    • Wybór najlepszej z ustalonych funkcji.
    • Przyjrzyjmy się tym krokom bardziej szczegółowo.

      Rozpoznawanie przeciążenia funkcji nie ma zastosowania, jeśli wszystkie operandy są typów wbudowanych. W takim przypadku gwarantowane jest użycie wbudowanego operatora. (Korzystanie z operatorów z wbudowanymi operandami typu zostało omówione w rozdziale 4.) Na przykład:

    class SmallInt ( public: SmallInt(int); ); Operator SmallInt+ (const SmallInt &, const SmallInt &); void func() ( int i1, i2; int i3 = i1 + i2; )

    Ponieważ operandy i1 i i2 są typu int, a nie klasy, dodatkowo używany jest wbudowany operator +. Przeciążony operator+(const SmallInt &, const SmallInt &) jest ignorowany, chociaż operandy można rzutować na SmallInt przy użyciu konwersji zdefiniowanej przez użytkownika w postaci konstruktora SmallInt(int). Opisany poniżej proces rozwiązywania problemów z przeciążeniem nie ma zastosowania w takich sytuacjach.

    Ponadto rozpoznawanie przeciążenia dla operatorów jest używane tylko w przypadku korzystania ze składni operatora:

    Void func() ( SmallInt si(98); int iobj = 65; int res = si + iobj; // użyta składnia operatora)

    Jeśli zamiast tego użyjemy składni wywołania funkcji: int res = operator+(si, iobj); // składnia wywołania funkcji

    wtedy ma zastosowanie procedura rozwiązywania przeciążeń dla funkcji w przestrzeni nazw (patrz Rozdział 15.10). Jeśli używana jest składnia do wywoływania funkcji składowej:

    // składnia wywołania funkcji składowych int res = si.operator+(iobj);

    wtedy działa odpowiednia procedura dla funkcji składowych (patrz rozdział 15.11).

    15.12.1. Funkcje operatora kandydującego

    Funkcja operatora jest kandydatem, jeśli ma taką samą nazwę jak wywołana. W przypadku korzystania z następującego operatora dodawania

    SmallInt si(98); intioobj = 65; int res = si + iobj;

    funkcja operatora kandydującego to operator+. Jakie deklaracje Operator+ są brane pod uwagę?

    Potencjalnie, w przypadku użycia składni operatora z operandami typu klasy, konstruowanych jest pięć zestawów kandydatów. Pierwsze trzy są takie same jak w przypadku wywoływania zwykłych funkcji z argumentami typu klasy:

    • zestaw operatorów widocznych w punkcie wywołania. Deklaracje funkcji operator+() widoczne w miejscu użycia operatora są kandydatami. Na przykład operator+() zadeklarowany w zasięgu globalnym jest kandydatem, jeśli operator+() jest używany wewnątrz main():
    Operator SmallInt+ (const SmallInt &, const SmallInt &); int main() ( SmallInt si(98); int iobj = 65; int res = si + iobj; // ::operator+() jest funkcją kandydującą)
  • zestaw operatorów zadeklarowanych w przestrzeni nazw, w której zdefiniowany jest typ operandu. Jeśli operand jest typu klasy i ten typ jest zadeklarowany w przestrzeni nazw zdefiniowanej przez użytkownika, to funkcje operatora zadeklarowane w tej samej przestrzeni i mające taką samą nazwę jak użyty operator są uważane za kandydatów:
  • przestrzeń nazw NS ( class SmallInt ( /* ... */ ); SmallInt operator+ (const SmallInt&, double); ) int main() ( // si jest typu SmallInt: // ta klasa jest zadeklarowana w przestrzeni nazw NS NS: :SmallInt si(15); // NS::operator+() - kandydująca funkcja int res = si + 566; return 0; )

    Operand si jest typu klasy SmallInt, zadeklarowanym w przestrzeni nazw NS. W związku z tym przeciążony operator+(const SmallInt, double) zadeklarowany w tym samym miejscu jest dodawany do zestawu kandydującego;

  • zbiór operatorów zadeklarowanych przyjaciółmi klas, do których należą operandy. Jeżeli operand należy do typu klasy i w definicji tej klasy znajdują się funkcje zaprzyjaźnione o tej samej nazwie do zastosowanego operatora, to są one dodawane do zbioru kandydatów:
  • przestrzeń nazw NS ( class SmallInt ( friend SmallInt operator+(const SmallInt&, int) ( /* ... */ ) ); ) int main() ( NS::SmallInt si(15); // zaprzyjaźniona funkcja operator+() - kandydat int res = si + 566; zwraca 0; )

    Operand si jest typu SmallInt. Funkcja operatora operator+(const SmallInt&, int), która jest przyjacielem tej klasy, jest członkiem przestrzeni nazw NS, chociaż nie jest bezpośrednio zadeklarowana w tej przestrzeni. Normalne wyszukiwanie w NS nie znajdzie tej funkcji operatora. Jednak w przypadku użycia operatora+() z argumentem typu SmallInt, zaprzyjaźnione funkcje zadeklarowane w zakresie tej klasy są uwzględniane i dodawane do zbioru kandydatów. Te trzy zestawy kandydujących funkcji operatorskich są tworzone w taki sam sposób, jak w przypadku normalnych wywołań funkcji z argumentami typu klasy. Jednak przy użyciu składni operatorów budowane są dwa dodatkowe zestawy:

    • zbiór operatorów członkowskich zadeklarowanych w klasie lewego operandu. Jeśli taki operand operatora+() ma typ klasy, to deklaracje operatora+() należące do tej klasy są zawarte w zestawie funkcji kandydujących:
    klasa myFloat ( myFloat(double); ); class SmallInt ( public: SmallInt(int); operator SmallInt+ (const myFloat &); ); int main() ( SmallInt si(15); int res = si + 5.66; // kandydujący operator członkowski+() )

    Operator składowy SmallInt::operator+(const myFloat &) zdefiniowany w SmallInt jest zawarty w zestawie funkcji kandydujących do rozwiązania wywołania operator+() w main();

  • wiele wbudowanych operatorów. Biorąc pod uwagę typy, które mogą być używane z wbudowanym operatorem+(), kandydatami są również:
  • int operator+(int, int); podwójny operator+(podwójny, podwójny); Operator T*+(T*, I); operator T*+(I, T*);

    Pierwsza deklaracja dotyczy wbudowanego operatora do dodawania dwóch wartości typów całkowitych, druga dla operatora do dodawania wartości typów zmiennoprzecinkowych. Trzeci i czwarty odpowiadają wbudowanemu operatorowi dodawania typu wskaźnika, który służy do dodawania liczby całkowitej do wskaźnika. Ostatnie dwie deklaracje są symboliczne i opisują całą rodzinę wbudowanych operatorów, które kompilator może wybrać jako kandydatów podczas przetwarzania operacji dodawania.

    Każdy z pierwszych czterech zestawów może być pusty. Na przykład, jeśli wśród elementów klasy SmallInt nie ma funkcji o nazwie operator+(), czwarty zestaw będzie pusty.

    Cały zestaw kandydujących funkcji operatorskich jest sumą pięciu podzbiorów opisanych powyżej:

    Przestrzeń nazw NS ( class myFloat ( myFloat(double); ); class SmallInt ( przyjaciel SmallInt operator+(const SmallInt &, int) ( /* ... */ ) public: SmallInt(int); operator int(); SmallInt operator+ ( const myFloat &); // ... ); SmallInt operator+ (const SmallInt &, double); ) int main() ( // type si - class SmallInt: // Ta klasa jest zadeklarowana w przestrzeni nazw NS NS::SmallInt si (15); int res = si + 5.66; // który operator+()? zwraca 0; )

    Te pięć zestawów obejmuje siedem funkcji operatora kandydującego do roli operatora+() w funkcji main():

      pierwszy zestaw jest pusty. W zakresie globalnym, czyli tym, w którym operator+() jest używany w funkcji main(), nie ma deklaracji przeciążonego operatora+();
    • drugi zestaw zawiera operatory zadeklarowane w przestrzeni nazw NS, w której zdefiniowana jest klasa SmallInt. W tej przestrzeni znajduje się jeden operator: NS::SmallInt NS::operator+(const SmallInt &, double);
    • trzeci zestaw zawiera operatorów zadeklarowanych jako przyjaciele klasy SmallInt. Obejmuje to NS::SmallInt NS::operator+(const SmallInt &, int);
    • czwarty zbiór zawiera operatory zadeklarowane jako członkowie SmallInt. Jest też jeden: NS::SmallInt NS::SmallInt::operator+(const myFloat &);
    • piąty zestaw zawiera wbudowane operatory binarne:
    int operator+(int, int); podwójny operator+(podwójny, podwójny); Operator T*+(T*, I); operator T*+(I, T*);

    Tak, generowanie zbioru kandydatów do rozwiązania operatora używanego ze składnią operatora jest żmudne. Ale po jego skonstruowaniu, trwałe funkcje i najlepsze z nich są znajdowane, jak poprzednio, poprzez analizę transformacji zastosowanych do argumentów wybranych kandydatów.

    15.12.2. Ugruntowane cechy

    Zbiór ustalonych funkcji operatorskich jest tworzony ze zbioru kandydatów poprzez wybranie tylko tych operatorów, które mogą być wywołane za pomocą danych operandów. Na przykład, który z siedmiu kandydatów znalezionych powyżej utrzyma się? Operator jest używany w następującym kontekście:

    NS::SmallInt si(15); si + 5,66;

    Lewy operand jest typu SmallInt, a prawy operand jest podwójny.

    Pierwszy kandydat to ugruntowana funkcja dla to zastosowanie operator+():

    Lewy operand typu SmallInt jako inicjator dokładnie pasuje do formalnego parametru referencyjnego tego przeciążonego operatora. Prawy, który jest typu double, również dokładnie pasuje do drugiego parametru formalnego.

    Będzie również obowiązywać następująca funkcja kandydująca:

    NS::SmallInt NS::operator+(const SmallInt &, int);

    Lewy operand si typu SmallInt jako inicjator dokładnie pasuje do formalnego parametru odwołania przeciążonego operatora. Prawy jest typu int i może być rzutowany na typ drugiego parametru formalnego przy użyciu standardowej konwersji.

    Trzecia funkcja kandydująca będzie również posiadać:

    NS::SmallInt NS::SmallInt::operator+(const myFloat &);

    Lewy operand si jest typu SmallInt, tj. typ klasy, której członkiem jest przeciążony operator. Prawy jest typu int i jest rzutowany na typ klasy myFloat przy użyciu zdefiniowanej przez użytkownika konwersji w postaci konstruktora myFloat(double).

    Czwartą i piątą ustalonymi funkcjami są wbudowane operatory:

    int operator+(int, int); podwójny operator+(podwójny, podwójny);

    Klasa SmallInt zawiera konwerter, który może rzutować wartość SmallInt na int. Ten konwerter jest używany w połączeniu z pierwszym wbudowanym operatorem do konwersji lewego operandu na int. Drugi operand typu double jest konwertowany na typ int przy użyciu standardowej konwersji. Jeśli chodzi o drugi wbudowany operator, konwerter konwertuje lewy operand z typu SmallInt na typ int, po czym wynik jest standardowo konwertowany na double. Drugi operand typu double dokładnie pasuje do drugiego parametru.

    Najlepszą z tych pięciu trwałych funkcji jest pierwsza, operator+(), zadeklarowana w przestrzeni nazw NS:

    NS::SmallInt NS::operator+(const SmallInt &, double);

    Oba jego operandy dokładnie odpowiadają parametrom.

    15.12.3. Niejasność

    Obecność w tej samej klasie konwerterów, które wykonują niejawne konwersje na typy wbudowane i przeciążone operatory, może prowadzić do niejednoznaczności podczas wyboru między nimi. Na przykład istnieje następująca definicja klasy String z funkcją porównania:

    Class String ( // ... public: String(const char * = 0); operator bool== (const String &) const; // brak operatora operator== (const char *) );

    i to użycie operatora==:

    Kwiat sznurkowy("tulipan"); void foo(const char *pf) ( // wywołanie przeciążonego operatora String::operator==() if (flower == pf) cout<< pf <<" is a flower!\en"; // ... }

    Następnie porównując

    Kwiat == pf

    operator równości klasy String nazywa się:

    Aby przekształcić prawy operand pf z typu const char* na typ String parametru operator==(), stosowana jest konwersja zdefiniowana przez użytkownika, która wywołuje konstruktor:

    Ciąg(const znak *)

    Jeśli do definicji klasy String dodamy konwerter typu const char*:

    Class String ( // ... public: String(const char * = 0); operator bool== (const String &) const; operator const char*(); // nowy konwerter );

    wtedy pokazane użycie operatora==() staje się niejednoznaczne:

    // kontrola równości już się nie kompiluje! jeśli (kwiat == pf)

    Ze względu na dodanie operatora konwertera const char*() wbudowany operator porównania

    jest również uważana za funkcję stabilną. Dzięki temu lewy kwiat operandu typu String można przekonwertować na typ const char *.

    Istnieją teraz dwie ustalone funkcje operatorskie do używania operator==() w foo(). Pierwszy

    String::operator==(const String &) const;

    wymaga zdefiniowanej przez użytkownika konwersji prawego operandu pf z const char* na String. Drugi

    operator bool==(const char *, const char *)

    wymaga niestandardowej konwersji lewego operandu flower z String na const char*.

    Tak więc pierwsza dobrze ustalona funkcja jest lepsza dla lewego operandu, a druga jest lepsza dla prawego. Ponieważ nie ma najlepszej funkcji, wywołanie jest oznaczone przez kompilator jako niejednoznaczne.

    Projektując interfejs klasy, który zawiera deklarację przeciążonych operatorów, konstruktorów i konwerterów, musisz być bardzo ostrożny. Konwersje zdefiniowane przez użytkownika są stosowane niejawnie przez kompilator. Może to spowodować, że wbudowane operatory będą odporne na rozpoznawanie przeciążenia dla operatorów z operandami typu klasy.

    Ćwiczenie 15.17

    Nazwij pięć zestawów funkcji kandydujących uwzględnionych w rozwiązywaniu przeciążenia operatora za pomocą operandów typu klasy.

    Ćwiczenie 15.18

    Który operator+() zostanie wybrany jako najskuteczniejszy operator dodawania w main()? Wymień wszystkie funkcje kandydujące, wszystkie ustanowione funkcje i konwersje typów, które mają być zastosowane do argumentów dla każdej ustanowionej funkcji.

    Przestrzeń nazw NS ( class complex ( complex(double); // ... ); class LongDouble ( przyjaciel LongDouble operator+(LongDouble &, int) ( /* ... */ ) public: LongDouble(int); operator double() ; LongDouble operator+(const complex &); // ... ); LongDouble operator

    Podstawy przeciążania operatora

    C#, podobnie jak każdy język programowania, ma zestaw tokenów, które służą do wykonywania podstawowych operacji na typach wbudowanych. Na przykład wiemy, że operację + można zastosować do dwóch liczb całkowitych, aby uzyskać ich sumę:

    // Operacja + z liczbami całkowitymi. int a = 100; intb = 240; int c = a + b; //s jest teraz 340

    Nic nowego, ale czy kiedykolwiek myślałeś, że tę samą operację + można zastosować do większości wbudowanych typów danych C#? Rozważmy na przykład ten kod:

    // Operacja + z ciągami. ciąg si = "Cześć"; ciąg s2 = "świat!"; ciąg s3 = si + s2; // s3 zawiera teraz "Witaj świecie!"

    Zasadniczo funkcjonalność operacji + jest jednoznacznie oparta na reprezentowanych typach danych (w tym przypadku ciągi lub liczby całkowite). Kiedy operator + jest stosowany do typów numerycznych, otrzymujemy arytmetyczną sumę operandów. Jednak gdy ta sama operacja zostanie zastosowana do typów ciągów, powstaje konkatenacja ciągów.

    Język C# zapewnia możliwość budowania specjalnych klas i struktur, które również jednoznacznie reagują na ten sam zestaw tokenów podstawowych (takich jak operator +). Należy pamiętać, że absolutnie każdy wbudowany operator języka C# nie może być przeciążony. Poniższa tabela opisuje możliwości przeciążania głównych operacji:

    Operacja C# Możliwość przeciążenia
    +, -, !, ++, --, prawda, fałsz Ten zestaw operacji jednoargumentowych może być przeciążony
    +, -, *, /, %, &, |, ^, > Te operacje binarne mogą być przeciążone
    ==, !=, <, >, <=, >= Te operatory porównania mogą być przeciążone. C# wymaga wspólnego przeciążania operatorów „podobnych” (tj.< и >, <= и >=, == i !=)
    Operacja nie może być przeciążona. Jednak indeksatory oferują podobną funkcjonalność.
    () Operator () nie może być przeciążony. Jednak tę samą funkcjonalność zapewniają specjalne metody konwersji
    +=, -=, *=, /=, %=, &=, |=, ^=, >= Operatory przypisania skróconego nie mogą być przeciążone; jednak otrzymujesz je automatycznie, przeciążając odpowiednią operację binarną

    Przeciążanie operatorów jest ściśle związane z przeciążaniem metod. Operator jest przeciążony słowem kluczowym operator Definiuje metodę operatora, która z kolei definiuje akcję operatora w odniesieniu do jego klasy. Istnieją dwie formy metod operatorskich (operatorów): jedna dla operatorów jednoargumentowych, druga dla binarnych. Poniżej znajduje się ogólny formularz dla każdej odmiany tych metod:

    // Ogólna forma przeciążenia operatora jednoargumentowego. public static return_type operator op(operand parametr_type) ( // operacje ) // Ogólna forma przeciążania operatorów binarnych. publiczny statyczny operator typu return_type op(operand_typu_parametru1, operand_typu_parametru2) ( // operacje )

    Tutaj op zostaje zastąpiony przez przeciążony operator, taki jak + lub /, oraz typ_zwrotu oznacza określony typ wartości zwracanej przez określoną operację. Ta wartość może być dowolnego typu, ale często jest określana jako tego samego typu, co klasa, dla której operator jest przeciążany. Ta korelacja ułatwia używanie przeciążonych operatorów w wyrażeniach. Dla operatorów jednoargumentowych operand oznacza przesyłany operand, a dla operatorów binarnych to samo jest oznaczane argument1 oraz argument 2. Należy zauważyć, że metody operatora muszą mieć zarówno publiczne, jak i statyczne specyfikatory typu.

    Przeciążanie operatorów binarnych

    Spójrzmy na użycie przeciążania operatorów binarnych na najprostszym przykładzie:

    Korzystanie z systemu; za pomocą System.Collections.Generic; za pomocą System.Linq; za pomocą System.Text; namespace ConsoleApplication1 ( class MyArr ( // Współrzędne punktu w przestrzeni 3D public int x, y, z; public MyArr(int x = 0, int y = 0, int z = 0) ( this.x = x; this. y = y; this.z = z; ) // Przeciążenie operatora binarnego + publiczny statyczny operator MyArr +(MyArr obj1, MyArr obj2) ( MyArr arr = new MyArr(); arr.x = obj1.x + obj2.x; arr.y = obj1.y + obj2.y; arr.z = obj1.z + obj2.z; return arr; ) // Przeciążenie operatora binarnego - publiczny statyczny operator MyArr -(MyArr obj1, MyArr obj2) ( MyArr arr = new MyArr (); arr.x = obj1.x - obj2.x; arr.y = obj1.y - obj2.y; arr.z = obj1.z - obj2.z; return arr; ) ) class Program ( statyczny void Main (args string) ( MyArr Point1 = new MyArr(1, 12, -4); MyArr Point2 = new MyArr(0, -3, 18); Console.WriteLine("Współrzędne pierwszego punktu: " + Point1.x + " " + Point1.y + " " + Point1.z); Console.WriteLine("Współrzędne drugiego punktu: " + Point2.x + " " + Point2.y + " " + Point2.z + "\n"); MojeArr Punkt3 = Punkt1 + Punkt2; Console.WriteLine("\nPoint1 + Point2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Punkt3 = Punkt1 - Punkt2; Console.WriteLine("\nPunkt1 - Punkt2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Konsola.CzytajLinię(); ) ) )

    Przeciążanie operatorów jednoargumentowych

    Operatory jednoargumentowe są przeciążane w taki sam sposób jak binarne. Główną różnicą jest oczywiście to, że mają tylko jeden operand. Zmodernizujmy poprzedni przykład, dodając przeciążenia operatorów ++, --, -:

    Korzystanie z systemu; za pomocą System.Collections.Generic; za pomocą System.Linq; za pomocą System.Text; namespace ConsoleApplication1 ( class MyArr ( // Współrzędne punktu w przestrzeni 3D public int x, y, z; public MyArr(int x = 0, int y = 0, int z = 0) ( this.x = x; this. y = y; this.z = z; ) // Przeciążenie operatora binarnego + publiczny statyczny operator MyArr +(MyArr obj1, MyArr obj2) ( MyArr arr = new MyArr(); arr.x = obj1.x + obj2.x; arr.y = obj1.y + obj2.y; arr.z = obj1.z + obj2.z; return arr; ) // Przeciążenie operatora binarnego - publiczny statyczny operator MyArr -(MyArr obj1, MyArr obj2) ( MyArr arr = new MyArr (); arr.x = obj1.x - obj2.x; arr.y = obj1.y - obj2.y; arr.z = obj1.z - obj2.z; return arr; ) // Przeciążenie jednoargumentowe operator - publiczny statyczny operator MyArr -(MyArr obj1) ( MyArr arr = new MyArr(); arr.x = -obj1.x; arr.y = -obj1.y; arr.z = -obj1.z; return arr; ) // Przeciążenie operatora jednoargumentowego ++ public static Operator MyArr ++(MyArr obj1) ( obj1.x += 1; obj1.y += 1; obj1.z +=1; return obj1; ) // Przeciążenie jednoargumentowego operator -- publikuj c statyczny operator MyArr --(MyArr obj1) ( obj1.x -= 1; obj1.y -= 1; obj1.z -= 1; zwróć obj1; ) ) class Program ( static void Main(string args) ( MyArr Point1 = new MyArr(1, 12, -4); MyArr Point2 = new MyArr(0, -3, 18); Console.WriteLine("Współrzędne pierwszego punktu: " + Point1.x + " " + Point1.y + " " + Point1.z); Console.WriteLine("Współrzędne drugiego punktu: " + Point2.x + " " + Point2.y + " " + Point2. z + "\n"); MojeArr Punkt3 = Punkt1 + Punkt2; Console.WriteLine("\nPunkt1 + Punkt2 = " + Punkt3.x + " " + Punkt3.y + " " + Punkt3.z); Punkt3 = Punkt1 - Point2; Console.WriteLine("Punkt1 - Point2 = " + Point3.x + " " + Point3.y + " " + Point3.z); Point3 = -Point1; Console.WriteLine("-Point1 = " + Point3.x + " " + Point3.y + " " + Point3.z); Point2++; Console.WriteLine("Point2++ = " + Point2.x + " " + Point2.y + " " + Point2.z); Point2--; Konsola. WriteLine("Point2-- = " + Point2.x + " " + Point2.y + " " + Point2.z); Console.ReadLine(; ) ) )

    Wiele języków programowania wykorzystuje operatory: co najmniej przypisania (= , := lub podobne) i operatory arytmetyczne (+ , - , * i /). W większości języków z typami statycznymi operatory te są powiązane z typami. Na przykład w Javie dodawanie z operatorem + jest możliwe tylko dla liczb całkowitych, liczb zmiennoprzecinkowych i łańcuchów. Jeśli zdefiniujemy własne klasy dla obiektów matematycznych, takich jak macierze, możemy zaimplementować metodę ich dodawania, ale można ją wywołać tylko w taki sposób: a = b.add(c) .

    W C++ nie ma takiego ograniczenia - możemy przeciążyć prawie każdy znany operator. Możliwości są nieograniczone: możesz wybrać dowolną kombinację typów operandów, jedynym ograniczeniem jest to, że musi być obecny co najmniej jeden operand typu zdefiniowanego przez użytkownika. Oznacza to, że zdefiniuj nowy operator we wbudowanych typach lub przepisz istniejący to jest zabronione.

    Kiedy należy przeciążać operatorów?

    Pamiętaj o głównej rzeczy: przeciążaj operatory wtedy i tylko wtedy, gdy ma to sens. To znaczy, jeśli znaczenie przeciążenia jest oczywiste i nie niesie za sobą ukrytych niespodzianek. Przeciążone operatory powinny zachowywać się tak samo, jak ich wersje podstawowe. Oczywiście wyjątki są dopuszczalne, ale tylko w przypadkach, gdy towarzyszą im zrozumiałe wyjaśnienia. Dobrym przykładem są operatorzy<< и >> standardowa biblioteka iostream , która wyraźnie zachowuje się inaczej niż normalnie .

    Oto dobre i złe przykłady przeciążania operatorów. Powyższe dodanie macierzy jest przypadkiem ilustracyjnym. Tutaj przeciążanie operatora dodawania jest intuicyjne i, jeśli zostało poprawnie zaimplementowane, nie wymaga wyjaśnień:

    Macierz a, b; Macierz c = a + b;

    Przykładem złego przeciążenia operatora dodawania może być dodanie dwóch obiektów gracza w grze. Co miał na myśli twórca klasy? Jaki będzie wynik? Nie wiemy, co robi ta operacja, dlatego używanie tego operatora jest niebezpieczne.

    Jak przeciążać operatorów?

    Przeciążanie operatorów jest podobne do przeciążania funkcji nazwami specjalnymi. W rzeczywistości, gdy kompilator widzi wyrażenie zawierające operator i typ zdefiniowany przez użytkownika, zastępuje to wyrażenie wywołaniem odpowiedniej funkcji przeciążonego operatora. Większość ich nazw zaczyna się od słowa kluczowego operator, po którym następuje nazwa operatora. Gdy oznaczenie nie składa się ze znaków specjalnych, na przykład w przypadku rzutowania lub operatora zarządzania pamięcią (new , delete itp.), słowo operator i oznaczenie operatora muszą być oddzielone spacją (operator nowy), w przeciwnym razie spację można zignorować (operator+ ).

    Większość operatorów może być przeciążona zarówno przez metody klas, jak i proste funkcje, ale jest kilka wyjątków. Gdy przeciążony operator jest metodą klasy, typ pierwszego operandu musi być tą klasą (zawsze *this), a drugi musi być zadeklarowany na liście parametrów. Ponadto instrukcje metod nie są statyczne, z wyjątkiem instrukcji zarządzania pamięcią.

    Przeciążając operator w metodzie klasy, uzyskuje dostęp do prywatnych pól klasy, ale ukryta konwersja pierwszego argumentu nie jest dostępna. Dlatego funkcje binarne są zwykle przeciążane jako funkcje wolne. Przykład:

    Class Rational ( public: //Constructor może być użyty do niejawnej konwersji z int: Rational(int licznik, int denominator = 1); Operator racjonalny+(Rational const& rhs) const; ); int main() ( Wymierne a, b, c; int i; a = b + c; //ok, konwersja nie jest konieczna a = b + i; //ok, niejawna konwersja drugiego argumentu a = i + c; //BŁĄD: pierwszy argument nie może być niejawnie przekonwertowany )

    Gdy operatory jednoargumentowe są przeciążone jako wolne funkcje, dostępna jest dla nich konwersja ukrytych argumentów, ale zwykle nie jest ona używana. Z drugiej strony ta właściwość jest niezbędna dla operatorów binarnych. Więc główną radą byłoby:

    Implementuj operatory jednoargumentowe i operatory binarne, takie jak „ X=” jako metody klas, a inne operatory binarne jako wolne funkcje.

    Jacy operatorzy mogą być przeciążeni?

    Możemy przeciążyć prawie każdy operator C++, z zastrzeżeniem następujących wyjątków i ograniczeń:

    • Nie można zdefiniować nowego operatora, takiego jak operator** .
    • Następujące operatory nie mogą być przeciążone:
      1. ?: (operator potrójny);
      2. :: (dostęp do nazw zagnieżdżonych);
      3. . (dostęp do pól);
      4. .* (dostęp do pola za pomocą wskaźnika);
      5. Operatory sizeof , typeid i cast .
    • Następujące operatory mogą być przeciążane tylko jako metody:
      1. = (zadanie);
      2. -> (dostęp do pól za pomocą wskaźnika);
      3. () (wywołanie funkcji);
      4. (dostęp przez indeks);
      5. ->* (dostęp wskaźnik do pola przez wskaźnik);
      6. operatory konwersji i zarządzania pamięcią.
    • Liczba operandów, kolejność wykonywania i asocjatywność operatorów jest określona przez wersję standardową.
    • Co najmniej jeden operand musi być typem zdefiniowanym przez użytkownika. Typedef się nie liczy.

    W następnej części zapoznasz się z przeciążonymi operatorami C++, w grupach i indywidualnie. Każda sekcja charakteryzuje się semantyką, tj. spodziewane zachowanie. Dodatkowo pokazane zostaną typowe sposoby deklarowania i wdrażania operatorów.