cpp

Wprowadzenie

Specyfikator typu auto oznacza, że kompilator ma wywnioskować typ na podstawie typu wyrażenia inicjalizującego. Kompilator podstawia wywniowskowany typ za specyfikator auto. Tego specyfikatora można użyć w definicji typu:

Specyfikator typu auto pozwala na pisanie uogólnionego kodu, bo już nie musimy podawać konkretnego typu, a możemy poprosić kompilator o wywniowskowanie go.

Motywacja

Pisanie typów w starym C++ było niewygodne, pracochłonne, a przy tym łatwo można było popełnić błędy, których kompilator czasami nie był w stanie wychwycić. Typowy przykład: żeby iterować po kontenerze kontenerów, musieliśmy wyliterować typ iteratora. Teraz łatwo zadeklarować iterator definiując jego typ z użyciem specyfikatora auto. Oto przykład:

#include <deque>
#include <iostream>
#include <vector>

int
main()
{
  std::deque<std::vector<int>> d;

  // We iterate using iterators with an explicitely declared type.
  for(std::deque<std::vector<int>>::iterator i = d.begin();
      i != d.end(); ++i)
    for(std::vector<int>::iterator j = i->begin();
        j != i->end(); ++j);

  // We iterate using iterators, but let the compiler deduce the type.
  for(auto i = d.begin(); i != d.end(); ++i)
    for(auto j = i->begin(); j != i->end(); ++j);
}

Podobnie dla kontenera typu T możemy użyć funkcji size, która zwraca wartość typu T::size_type, ale łatwiej jest nam użyć auto:

#include <iostream>
#include <vector>

using namespace std;

int
main()
{
  vector<int> v = {1, 2, 3};
  auto size = v.size();

  // We can iterate backward with an index.
  for(auto i = v.size(); i--;)
    cout << v[i] << endl;

  // We can iterate forward with an index.  We can ask the comiler to
  // deduce the type from 0, but we cannot be sure it will be the same
  // as vector<int>::size_type.
  for(auto i = 0; i < v.size(); ++i)
    cout << v[i] << endl;

  // This is correct, but without type deduction.
  for(vector<int>::size_type i = 0; i < v.size(); ++i)
    cout << v[i] << endl;

  // We ask the compiler to take the type of an expression without
  // deduction.  This is correct, and the most general, but somehow I
  // don't like it.
  for(decltype(v.size()) i = 0; i < v.size(); ++i)
    cout << v[i] << endl;
}

Czasami nie jesteśmy w stanie podać typu, bo go nie znamy, jak w przypadku domknięcia, czyli funktora typu anonimowego, który jest wynikiem wyrażenia lambda.

int
main()
{
  auto c = []{};
}

Na razie sprawa wydaje się prosta, bo w definicji typu użyliśmy tylko auto, ale definicja typu może zawierać dodatkowo także kwalifikatory i deklaratory.

Wnioskowanie typu zmiennej

Wnioskowanie typu auto jest takie same, jak wnioskowanie typowych argumentów szablonu.

Inicjalizacja zmiennej wygląda tak:

type name = expression;

Typ type zmiennej name może zawierać kwalifikatory (const, volatile). Dodatkowo type może zawierać deklarator & typu referencyjnego i deklarator * typu wskaźnikowego. Interesuje nas sytuacja, kiedy typ zmiennej zawiera specyfikator auto. Na przykład:

const auto &t = 1;

Kompilator traktuje taką inicjalizację zmiennej jak inicjalizację parametru funkcji w szablonie funkcj, gdzie:

Zadaniem kompilatora jest wywnioskowanie argumentu takiego urojonego szablonu (urojonego, bo nie jest zapisany w kodzie, a jedynie go sobie wyobrażamy) i podstawienie go w miejsce auto.

Przykłady

Poniższe przykłady nie powinny być trudne do zrozumienia, bo znamy już zasady wnioskowania. Żeby sprawdzić, że poprawnie myślimy (wnioskujemy), możemy w przykładach wykorzystać poniższą sztuczkę. Kompilacja zakończy się błędem, w którym będzie podany wywnioskowany typ.

template <typename T>
class ER;

int
main()
{
  // auto = int
  auto x = 1;

  // Uncomment the line to see the type of x.
  // ER<decltype(x)> er;
}

A tu wersja wariadyczna:

template <typename... T>
class ER;

template <typename... T>
void foo(T... t)
{
  ER<T...> er;
}

void goo(auto... t)
{
  ER<decltype(t)...> er;
}

int
main()
{
  // Uncomment the lines to see the types reported.
  // foo(1, .1);
  // goo(1, .1);
}

Typ referencyjny lub wskaźnikowy

Możemy zadeklarować referencję do danej typu, który kompilator ma sam wywnioskować. Daną może być inna zmienna, funkcja czy tablica. Oto przykład:

void
foo()
{
}

int
main()
{
  const volatile int x = 1;

  // A reference to a variable.
  // auto = const volatile int
  auto &r1 = x;
  // auto = volatile int
  const auto &r2 = x;
  // auto = const int
  volatile auto &r3 = x;
  // auto = int
  const volatile auto &r4 = x;

  // A reference to a function.
  // auto = void()
  auto &f1 = foo;
  // The above is equivalent to this.
  using ft = void();
  ft &f2 = foo;

  // A reference to a C-style table.
  int t[] = {1, 2, 3};
  // auto = int[3]
  auto &t1 = t;
  // The above is equivalent to this.
  using t3i = int[3];
  t3i &t2 = t;
}

Podobnie dla wskaźników:

void foo()
{
}

int
main()
{
  const volatile int x = 1;

  // A pointer to a variable.
  // auto = const volatile int
  auto *r1 = &x;
  // auto = volatile int
  const auto *r2 = &x;
  // auto = const int
  volatile auto *r3 = &x;
  // auto = int
  const volatile auto *r4 = &x;

  // A pointer to a function.
  // auto = void()
  auto *f1 = foo;
  // The above is equivalent to this.
  using ft = void();
  ft *f2 = foo;

  // A pointer to a C-style table.
  int t[] = {1, 2, 3};
  // auto = int[3]
  auto *t1 = &t;
  // The above is equivalent to this.
  using t3i = int[3];
  t3i *t2 = &t;
}

Zwykły typ

Używając typu zwykłego (niereferencyjnego i niewskaźnikowego), możemy inicjalizować zmienną bez podawania jej typu. W ten sposób możemy upewnić się, że zmienna jest zainicjalizowana. Pamiętajmy, że to jedynie sztuczka, a nie jakaś mądrość programowania w C++.

Jeżeli wyrażenie inicjalizujące jest typu wskaźnikowego, to wywnioskowany typ będzie wskaźnikowy. W tym przypadku, wyrażenia inicjalizujące takie jak nazwa funkcji, nazwa tablicy, czy literał łańcuchowy rozpadną się (na wskaźnik).

Dla zmiennej zwykłego typu nigdy nie będzie wywnioskowany typ referencyjny, bo wyrażenie inicjalizujące nigdy nie jest typu referencyjnego.

double foo()
{
  return .0;
}

int *goo()
{
  return static_cast<int *>(0);
}

int &loo()
{
  static int l;
  return l;
}

int
main()
{
  // auto = int
  auto w = 1;
  // auto = int
  const auto x = 2;
  // auto = int
  auto y = w;
  // auto = int
  auto z = x;

  // auto = double
  auto a = foo();
  // auto = int *
  auto b = goo();

  // auto = double (*)()
  auto fp = foo;

  int t[] = {1, 2, 3};
  // auto = int *
  auto tp = t;

  // auto = const char *
  auto hw = "Hello World!";

  // auto = int
  auto l = loo();
}

decltype

W miejsce specyfikator typu decltype kompilator podstawia typ zmiennej albo wyrażenia, które są argumentem spefycikatora. Podstawiony typ może być dowolny, także referencyjny. Ale chwileczkę, czy przypadkiem nie było powiedziane, że wyrażenia nigdy nie są typu referencyjnego? Czy nie powinno być tak samo z decltype? A więc, w przypadku decltype deklarator & najwyższego rzędu nie jest usuwany: tak powiada standard.

#include <cassert>
#include <utility>

int &foo()
{
  static int i = 1;
  return i;
}

int main()
{
  double a = 0.1;
  // Variable b is of the double type.
  decltype(a) b = 1;

  int i, j = 1;
  int &x = i;

  // Variable y has the same type as variable x: an lvalue reference.
  decltype(x) y = j;
  y = 2;
  assert(j == 2);

  // Expression foo() is of the reference type to an integer, and so
  // is variable z.
  decltype(foo()) z = foo();
  z = 2;
  assert(foo() == 2);

  int &&r = 1;
  // Variable s is of the rvalue reference type to an integer.
  decltype(r) s = std::move(r);
  s = 2;
  assert(r == 2);
}

Jeżeli chcemy, żeby specyfikator decltype dostarczył typ wyrażenia inicjalizacyjnego, to używamy decltype(auto). To nie to samo, co auto, który stosuje zasady wnioskowania typowego argumentu szablonu. Oto przykłady:

#include <cassert>

int &
singleton()
{
  static int i = 0;
  return i;
}

int main()
{
  // auto = int
  auto i = singleton();
  i = 1;
  assert(singleton() == 0);

  // decltype(auto) = int &
  decltype(auto) r = singleton();
  r = 1;
  assert(singleton() == 1);
}

Specyfikator auto w pętli for dla zakresu

Specyfikator auto możemy użyć w pętli for dla zakresu, czyli w definicji typu deklarowanej zmiennej, tej dostępnej w ciele pętli. Chociaż użycie specyfikatora auto jest wygodne, to nie musimy z niego korzystać i możemy podać typ jawnie. Ale trzeba uważać, żeby nie popełnić błędu.

Przykład niżej pokazuje, jak łatwo można popełnić błąd, który jest trudny do wychwycenia. To jest błąd, który sam popełniłem, a którego nie rozumiałem przez długi czas. W przykładzie błędnie napisany jest typ zmiennej: const pair<int, string> &. Wydaje się, że jest dobrze, bo chcemy iterować używając referencji stałej do elementów kontenera, a wiemy, że elementem kontenera jest para typów klucza i wartości. Program się kompiluje, ale nie działa prawidłowo. Gdzie jest błąd?

Błąd jest w typie pierwszego elementu pary: klucze w kontenerze są typu stałego, a my zażądaliśmy typu niestałego. Zatem typ zmiennej pętli powinien być const pair<const int, string> &. Ten drobny błąd powoduje, że kompilator tworzy tymczasową parę elementów typu int oraz string i inicjalizuje ją przez kopiowanie wartości z pary w kontenerze. W ten sposób mamy, co chcieliśmy, czyli referencję stałą do pary żądanego typu.

Problem w tym, że ta tymczasowa para wkrótce wyparuje, bo jest alokowana na stosie jako dana lokalna ciała pętli. Problem, bo w wektorze zapisujemy referencję do ciągu znaków w parze, a ta referencja po zakończeniu iteracji odnosi się do nieistniejącego obiektu. Wypisując zawartość wektora widzimy ten sam ciąg znaków, bo tymczasowe pary były tworzone na stosie w tym samym miejscu, a my widzimy ostatnią wartość.

Ponieważ w kontenerach nie możemy przechowywać referencji (const string &), to użyliśmy std::reference_wrapper<const string>. Moglibyśmy użyć po prostu wskaźnika, ale std::reference_wrapper możemy używać podobnie jak referencję (chodzi o składnię i semantykę).

#include <iostream>
#include <functional>
#include <map>
#include <string>
#include <vector>

using namespace std;

int
main()
{
  map<int, string> m = { {1, "Alice"}, {2, "Bob"} };
  vector<std::reference_wrapper<const string>> names;

  for(const pair<int, string> &e: m)
    names.push_back(std::ref(e.second));

  for(const auto &e: names)
    cout << e.get() << endl;
}

Typ wyniku funkcji

Możemy zdefiniować typ wyniku funkcji z użyciem specyfikatora auto. W definicji tego typu możemy użyć kwalifikatorów (const, volatile) oraz deklaratorów (&, *).

W miejsce specyfikatora auto kompilator podstawia typ wywnioskowany na podstawie wyrażenia instrukcji powrotu, które jest wyrażeniem inicjalizującym zwracanej wartości. Sytuacja jest analogiczna do inicjalizacji parametru funkcji w szablonie funkcji, z tą różnicą, że zwracana wartość nie ma nazwy.

Oto kilka przykładów:

// auto = int[3]
auto &f1()
{
  static int t[] = {1, 2, 3};
  return t;
}

void foo()
{
};

auto *f2()
{
  return foo;
}

// auto = const int *
auto f3()
{
  static const int i = 0;
  return &i;
}

int main()
{
  // Function f1 returns an lvalue reference to a table of 3 integers,
  // and so we are able to initialize the reference below.
  int (&f1r1)[3] = f1();
  // The following is equivalent to the above.
  using f1t = int[3];
  f1t &f1r2 = f1();

  // We get a pointer to a function.
  void (*f)() = f2();

  // Here we just get a pointer to a const int.
  const int *r3 = f3();
}

Doskonałe zwracanie

Piszemy callable f, które wywołuje jakiś inny callable g. Nie znamy typu wyniku zwracanego przez g, ale chcemy, żeby f zwracała tą samą daną, jaką otrzymała od g. Jest to problem doskonałego zwracania wyniku funkcji, w którym chodzi o:

Rozwiązanie: typ wyniku f ma być taki sam jak typ wyniku g. Konstruktor (kopiujący, przenoszący) dla przekazywanego wyniku nie będzie wywołany, bo jeżeli g zwraca wynik przez:

W poprawnej implementacji, callable f powinno mieć zadeklarowany typ wracanej wartości jako decltype(auto), a wyrażenie wywołania callable g powinno być wyrażeniem instrukcji powrotu callable f. Specyfikator decltype(auto) gwarantuje nam identyczny typ. Specyfikator auto wnioskowałby typ, a tego nie chcemy.

Oto przykład:

#include <iostream>

int &g1()
{
  static int i = 1;
  return i;
}

int g2()
{
  return 1;
}

template <typename G>
decltype(auto) f(G g)
{
  return g();
}

int main()
{
  f(g1) = 2;
  std::cout << g1() << std::endl;

  // Does not compile, because we can't assign to an rvalue.
  // f(g2) = 2;
}

Wyrażenie lambda i auto

W wyrażeniu lambda, specyfikatora auto możemy użyć w definicji typu parametru lub wyniku.

Typ parametru

W wyrażeniu lambda możemy zdefiniować parametry operatora wywołania z użyciem auto. Wtedy kompilator definiuje szablon składowej funkcji operatora wywołania, gdzie auto służy jako typowy parametr szablonu. Wywołanie domknięcia konkretyzuje szablon funkcji składowej. Oto przykład:

#include <iostream>

using namespace std;

int main()
{
  auto c = [x = 0](auto i) mutable
           {
             cout << __PRETTY_FUNCTION__ << ", "
                  << "x = " << ++x << ", "
                  << "i = " << i << endl;
           };

  c(1);
  c(.1);
  c("Hello!");
}

Typ wyniku

Domyślnym typem wyniku operator wywołania funkcji domknięcia jest auto. Z użyciem -> możemy jednak zdefiniować typ zwracanego wyniku jako decltype(auto), żeby zadbać o doskonałe zwracanie wyniku.

#include <iostream>

int &
g()
{
  static int a = 1;
  std::cout << a << std::endl;
  return a;
}

int main()
{
  // auto f = []() {return g();};
  auto f = []() -> decltype(auto) {return g();};
  f() = 10;
  g();
}

Skrócona składnia szablonu

Szablon funkcji możemy skrócić o nagłówek szablonu, jeżeli typ parametru funkcji zdefiniujemy z użyciem specyfikatora auto:

#include <iostream>

auto my_abs(auto t)
{
  if (t < 0)
    return -t;

  return t;
}

int main()
{
  std::cout << my_abs(-1) << std::endl;
  std::cout << my_abs(-1ll) << std::endl;
  std::cout << my_abs(-.1) << std::endl;
}

Jeżeli chcemy, żeby dwa parametry były tego samego typu, to specyfikator auto o to nie zadba, bo każde auto tworzy nowy parametr szablonu:

#include <iostream>

auto my_max(auto a, auto b)
{
  if (a < b)
    return b;

  return a;
}

int main()
{
  std::cout << my_max(1, 2) << std::endl;

  // std::cout << my_max(1, .1) << std::endl;
}

Szablon funkcji z paczkami parametrów możemy skrócić przez użycie specyfikatora typu auto, jak w przykładzie niżej. Jednak w tym przykładzie, parametr T musi być zdefiniowany, bo kompilator nie jest w stanie wywnioskować jego argumentu (nie ma parametru funkcji, który używa T), no i dodatkowo posługujemy się nim w ciele funkcji.

#include <iostream>
#include <string>
#include <vector>

template <typename T>
auto
factory(auto... p)
{
  return T{p...};
}

int
main()
{
  std::cout << factory<std::string>("Hello!") << std::endl;
  auto p = factory<std::vector<int>>(1, 2, 3);
}

Podsumowanie

Quiz