12.13 — Parametry wejścia i wyjścia

Funkcja i jej obiekt wywołujący komunikują się ze sobą za pomocą dwóch mechanizmów: parametrów i wartości zwracanych. Kiedy funkcja jest wywoływana, osoba wywołująca podaje argumenty, które funkcja otrzymuje poprzez swoje parametry. Argumenty te mogą być przekazywane przez wartość, referencję lub adres.

Zazwyczaj przekazujemy argumenty przez wartość lub przez stałą referencję. Są jednak chwile, kiedy możemy postąpić inaczej.

W parametrach

W większości przypadków parametr funkcji służy tylko do otrzymania danych wejściowych od osoby wywołującej. Parametry, które są używane tylko do odbierania danych wejściowych od obiektu wywołującego, są czasami wywoływane w parametrach.

#include <iostream>

void print(int x) // x is an in parameter
{
    std::cout << x << '\n';
}

void print(const std::string& s) // s is an in parameter
{
    std::cout << s << '\n';
}

int main()
{
    print(5);
    std::string s { "Hello, world!" };
    print(s);

    return 0;
}

Parametry wejściowe są zwykle przekazywane przez wartość lub przez odwołanie do stałej.

Parametry wyjściowe

Argument funkcji przekazywany przez odwołanie inne niż stała (lub wskaźnik do innej niż stała) pozwala funkcji modyfikować wartość obiektu przekazanego jako argument. Umożliwia to funkcji zwrócenie danych z powrotem do wywołującego w przypadkach, gdy użycie wartości zwracanej z jakiegoś powodu nie jest wystarczające.

Parametr funkcji używany tylko w celu zwrócenia informacji z powrotem do wywołującego nazywany jest parametrem wyjściowym.

Na przykład:

#include <cmath>    // for std::sin() and std::cos()
#include <iostream>

// sinOut and cosOut are out parameters
void getSinCos(double degrees, double& sinOut, double& cosOut)
{
    // sin() and cos() take radians, not degrees, so we need to convert
    constexpr double pi { 3.14159265358979323846 }; // the value of pi
    double radians = degrees * pi / 180.0;
    sinOut = std::sin(radians);
    cosOut = std::cos(radians);
}
 
int main()
{
    double sin { 0.0 };
    double cos { 0.0 };
 
    double degrees{};
    std::cout << "Enter the number of degrees: ";
    std::cin >> degrees;

    // getSinCos will return the sin and cos in variables sin and cos
    getSinCos(degrees, sin, cos);
 
    std::cout << "The sin is " << sin << '\n';
    std::cout << "The cos is " << cos << '\n';

    return 0;
}

Ta funkcja ma jeden parametr degrees (którego argument jest przekazywany przez wartość) jako wejście i „zwraca” dwa parametry (przez referencyjna) jako wyjście.

Nazwaliśmy te parametry wyjściowe przyrostkiem „out”, aby wskazać, że są to parametry wyjściowe. Pomaga to przypomnieć dzwoniącemu, że wartość początkowa przekazana do tych parametrów nie ma znaczenia i że powinniśmy spodziewać się ich nadpisania. Zgodnie z konwencją parametry wyjściowe są zazwyczaj parametrami znajdującymi się najbardziej na prawo.

Przyjrzyjmy się, jak to działa bardziej szczegółowo. Najpierw funkcja main tworzy zmienne lokalne sin i cos. Są one przekazywane do funkcji getSinCos() przez referencję (a nie przez wartość). Oznacza to, że funkcja getSinCos() ma dostęp do rzeczywistych sin i cos zmiennych w main(), a nie tylko kopiuje. getSinCos() odpowiednio przypisuje nowe wartości do sin i cos (odpowiednio poprzez odniesienia sinOut i cosOut ), co nadpisuje stare wartości w sin i cos. Funkcja main() następnie drukuje te zaktualizowane wartości.

Jeśli sin i cos został przekazany przez wartość zamiast odniesienia, getSinCos() zmieniłby kopie sin i cos, co spowodowałoby odrzucenie wszelkich zmian na końcu funkcji. Ponieważ jednak sin i cos przekazano przez odniesienie, wszelkie zmiany dokonane w sin lub cos (poprzez odniesienia) są utrwalane poza funkcją. Możemy zatem użyć tego mechanizmu do zwrócenia wartości z powrotem do obiektu wywołującego.

Na marginesie…

Ta odpowiedź na StackOverflow jest interesującą lekturą, która wyjaśnia, dlaczego odniesienia do wartości innych niż stałe nie mogą wiązać się z wartościami/obiektami tymczasowymi (ze względu na niejawną konwersję typów powodującą nieoczekiwane zachowanie w połączeniu z parametrami zewnętrznymi).

Parametry zewnętrzne mają nienaturalne zastosowanie składnia

Parametry zewnętrzne, choć funkcjonalne, mają kilka wad.

Po pierwsze, obiekt wywołujący musi utworzyć instancję (i zainicjować) obiekty i przekazać je jako argumenty, nawet jeśli nie zamierza ich używać. Do tych obiektów musi istnieć możliwość przypisania, co oznacza, że nie można ich uczynić stałymi.

Po drugie, ponieważ obiekt wywołujący musi przekazywać obiekty, wartości te nie mogą być używane jako tymczasowe ani łatwo używane w pojedynczym wyrażeniu.

Poniższy przykład pokazuje obie te wady:

#include <iostream>

int getByValue()
{
    return 5;
}

void getByReference(int& x)
{
    x = 5;
}

int main()
{
    // return by value
    [[maybe_unused]] int x{ getByValue() }; // can use to initialize object
    std::cout << getByValue() << '\n';      // can use temporary return value in expression

    // return by out parameter
    int y{};                // must first allocate an assignable object
    getByReference(y);      // then pass to function to assign the desired value
    std::cout << y << '\n'; // and only then can we use that value

    return 0;
}

Jak widać, składnia używania parametrów zewnętrznych jest nieco nienaturalne.

Parametry zewnętrzne przez odniesienie nie świadczą o tym, że argumenty zostaną zmodyfikowane

Kiedy przypiszemy wartość zwracaną przez funkcję do obiektu, jasne jest, że wartość obiektu jest modyfikowana:

x = getByValue(); // obvious that x is being modified

To dobrze, ponieważ wyjaśnia, że powinniśmy spodziewać się zmiany wartości x .

Przyjrzyjmy się jednak jeszcze raz wywołaniu funkcji do getSinCos() w powyższym przykładzie:

    getSinCos(degrees, sin, cos);

Tak nie jest z tego wywołania funkcji jasno wynika, że degrees jest parametrem wejściowym, ale sin i cos są parametrami wyjściowymi. Jeśli osoba wywołująca nie zorientuje się, że sin i cos zostanie zmodyfikowana, prawdopodobnie wystąpi błąd semantyczny.

Użycie przekazywania adresu zamiast przekazywania przez referencję może w niektórych przypadkach sprawić, że parametry out będą bardziej oczywiste, wymagając od osoby wywołującej przekazania adresu obiektów jako argumentów.

Rozważ następujący przykład:

void foo1(int x);  // pass by value
void foo2(int& x); // pass by reference
void foo3(int* x); // pass by address

int main()
{
    int i{};
 
    foo1(i);  // can't modify i
    foo2(i);  // can modify i (not obvious)
    foo3(&i); // can modify i

    int *ptr { &i };
    foo3(ptr); // can modify i (not obvious)

    return 0;
}

Zauważ, że w wywołaniu foo3(&i) musimy przekazać in &i zamiast i, co pomaga wyjaśnić, że powinniśmy spodziewać się i modyfikacji.

Nie jest to jednak niezawodne, ponieważ foo3(ptr) pozwala foo3() w celu modyfikacji i i nie wymaga, aby osoba wywołująca przyjęła adres ptr.

Osoba wywołująca może również pomyśleć, że może przekazać nullptr lub wskaźnik zerowy jako prawidłowy argument, gdy tak jest niedozwolone. Funkcja ta jest teraz wymagana do sprawdzania i obsługi wskaźnika zerowego, co zwiększa złożoność. Ta potrzeba dodatkowej obsługi wskaźnika zerowego często powoduje więcej problemów niż tylko trzymanie się przekazywania przez referencję.

Z tych wszystkich powodów należy unikać parametrów wyjściowych, chyba że nie istnieją inne dobre opcje.

Najlepsza praktyka

Unikaj parametrów wyjściowych (z wyjątkiem rzadkich przypadków, gdy nie ma lepszych opcji).

Preferuj przekazywanie przez referencje w przypadku nieopcjonalnych parametry wyjściowe.

Parametry wejściowe/wyjściowe

W rzadkich przypadkach funkcja faktycznie użyje wartości parametru wyjściowego przed nadpisaniem jego wartości. Taki parametr nazywany jest parametrem wejścia-wyjścia. Parametry wejściowe-wyjściowe działają identycznie jak parametry wyjściowe i wiążą się z tymi samymi wyzwaniami.

Kiedy przekazywać referencje inne niż stałe

Jeśli zamierzasz przekazywać przez referencje, aby uniknąć tworzenia kopii argumentu, prawie zawsze powinieneś przekazywać referencje stałe.

Nota autora

W poniższych przykładach użyjemy Foo do reprezentowania jakiegoś typu, na którym nam zależy. Na razie możesz sobie wyobrazić Foo jako alias typu dla wybranego typu (np. std::string).

Jednak istnieją dwa główne przypadki, w których przekazanie przez odwołanie inne niż stałe może być lepszym wyborem.

Po pierwsze użyj przekazania przez odwołanie inne niż stałe, gdy parametr jest parametrem wejściowym. Ponieważ przekazujemy już obiekt, którego potrzebujemy z powrotem, często jest to prostsze i wydajne, aby po prostu zmodyfikować ten obiekt.

void someFcn(Foo& inout)
{
    // modify inout
}

int main()
{
    Foo foo{};
    someFcn(foo); // foo modified after this call, may not be obvious

    return 0;
}

Nadanie funkcji dobrej nazwy może pomóc:

void modifyFoo(Foo& inout)
{
    // modify inout
}

int main()
{
    Foo foo{};
    modifyFoo(foo); // foo modified after this call, slightly more obvious

    return 0;
}

Alternatywą jest przekazanie obiektu przez wartość lub odwołanie do stałej (jak zwykle) i zwrócenie nowego obiektu według wartości, którą osoba wywołująca może następnie przypisać z powrotem do oryginalnego obiektu:

Foo someFcn(const Foo& in)
{
    Foo foo { in }; // copy here
    // modify foo
    return foo;
}

int main()
{
    Foo foo{};
    foo = someFcn(foo); // makes it obvious foo is modified, but another copy made here

    return 0;
}

Ma to tę zaletę, że używa bardziej konwencjonalnej składni zwracanej, ale wymaga wykonania 2 dodatkowych kopii (czasami kompilator może zoptymalizować jedną z tych kopii).

Po drugie, użyj przekazywania przez odwołanie inne niż stała, gdy w przeciwnym razie funkcja zwróciłaby obiekt według wartości do wywołującego, ale utworzenie kopii tego obiektu jest niezwykle kosztowne szczególnie, jeśli funkcja jest wywoływana wiele razy w sekcji kodu, która ma krytyczne znaczenie dla wydajności.

void generateExpensiveFoo(Foo& out)
{
    // modify out
}

int main()
{
    Foo foo{};
    generateExpensiveFoo(foo); // foo modified after this call

    return 0;
}

Dla zaawansowanych czytelników

Najczęstszym przykładem powyższego jest sytuacja, gdy funkcja musi wypełnić akcję. dużą tablicę w stylu C lub std::array z danymi, a tablica zawiera kosztowny w kopiowaniu element. Tablice omówimy w przyszłym rozdziale.

To powiedziawszy, kopiowanie obiektów rzadko jest tak drogie, że opłaca się uciekać do niekonwencjonalnych metod zwracania tych obiektów.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
94 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze