Toje pytanie nie koniecznie dotyczy programowania obiektowego jako takiego. Można je raczej zawęzić do samej funkcji i zwracania z niej argumentów.
Jeśli zwrócisz parę (std::pair<..., ...>), to ograniczysz się wyłącznie do 2 elementów. Możesz co prawda każdy z nich powielić (czyli mieć parę w parze która to osadzona para ma ... parę...), ale to jest droga do przepaści problemów zrozumienia intencji zawartych w kodzie.
Para ma tę pozytywną właściwość, że jest naturalna dla 2 wartości nazywanych first i second. Problem jednak w tym że nazwy te nie przenoszą kontekstu i intencji stosowania. Jeszcze raz, to nie jest złe, tylko ma swoje konsekwencje (właśnie braku kontekstu stosowania i ogólnych nazw first i second które nie wiadomo co dokładnie w kontekście znaczą).
Jeśli zwrócisz strukturę (czyli w tym przypadku obiekt VO - value object), to masz "pojemnik na dane". Każdy z atrybutów może coś znaczyć i może ich być wiele. Dodatkowo możesz mieć kontrolę nad poprawnością kreacji bo w C++ struktura może posiadać konstruktor lub metody walidujące które mogą nie dopuścić do tworzenia danych niepoprawnych.
Co do std::tuple<....>, do uwagi co do jasności intencji, są podobne jak dla std::pair<..., ...>. Tu masz jednak dostęp do danych przez std::get<X>(moja_tupla), gdzie X będzie miał wartości 0 do N co... znów nie mówi o co chodzi (pole pierwsze, pole drugie itd.). Krotka (jedna z nazw std::tuple<...>), przydaje się przy intensywnym stosowaniu szablonów oraz metaprogramowania i IMHO, nie powinna być wybierana jako domyślne rozwiązanie. Tym bardziej że std::get<X>(...), posiada argument w postaci szablonu i iterowanie po polach, wypcha Cię bezpośrednio w techniki szablonowe. Czasem tego chcesz, ale częściej bywa że nie :)
Możesz także wybrać (ale z całą pewnością nie jako domyślne rozwiązanie), zwrócenie wartości przez argument w funkcji. Jeśli będzie on referencją lub wskaźnikiem, nie będzie konieczności zwrócenia przez return. Tu jednak powinieneś mieć ważkie powody. Np. "ciężki obiekt" (co do objętości w pamięci lub kosztu kreacji), konieczność zwrócenia poprzez return z funkcji statusu (np. argumentu typu bool) lub inne. Funkcja wtedy będzie "wypełniała przesłany przez argument pojemnik na dane". Nie wybieraj jednak tego rozwiązania "bo tak", bo to nie jest dla osoby nieprzygotowanej intuicyjne. Czytelnik (i także Ty po kilku dniach), spodziewa się w pierwszej kolejności tradycyjnego działania funkcji ("wpada przez argument, zwraca przez return").
Jeszcze innym rozwiązaniem jest zwrócenie std::optional<...>. To wybierasz jednak np. wtedy, gdy zachodzi konieczność sygnalizowania że dane są dostępne lub ich brak. Dlaczego brak? Np. nie można ich uzyskać, przy tych argumentach w funkcji nie można ich policzyć itp. Takie podejście "wszystko albo nic".
Z funkcji można zwrócić także std::variant<...>. Najprościej (choć nie w sposób pełny), wytłumaczyć można ten typ danych jako unię. Jednak unię z mechanizmem wnioskowania jaki typ danych jest wewnątrz dostępny.
Jak dodasz do tego klasy to... jeszcze innym sposobem jest zwrócenie danych w postaci struktury/klasy, która definiowana jest wewnątrz funkcji i dziedziczy z argumentu zwracanego. Tu znów powinny być ważkie powody. Godzisz się na polimorfizm dynamiczny.
Hmm... może do tego ostatniego sposobu przykład. Jeśli inne sposoby nie są zrozumiałe, napisz.
#include <iostream>
#include <memory>
struct X {
virtual void foo() const {
std::cout << "X foo()\n";
}
};
std::unique_ptr<X> make(int i) {
struct Y: X {
void foo() const override {
std::cout << "Y foo()\n";
}
};
struct Z: X {
void foo() const override {
std::cout << "Z foo()\n";
}
};
if (i % 2) {
return std::make_unique<Y>();
}
return std::make_unique<Z>();
}
int main() {
auto x = make(2);
// Usuń poniżej komentarz i sprawdź jak działa
//auto x = make(1);
x->foo();
}
Jak widać możliwości chyba wystarczająco wiele :)