LINUX.ORG.RU

Цепочки методов в C++ (return void vs return this)

 


1

3

Пусть есть класс Foo:

class Foo
{
public:
  void setBar(Bar bar);
  Bar bar() const;

  void someAction();

  void anotherAction();
};

Модифицируем его API следующим образом:

class Foo
{
public:
  Foo& setBar(Bar bar);
  Bar bar() const;

  Foo& someAction();

  Foo& anotherAction();
};

Теперь вопрос к уважаемой публике. Какой API вам нравится больше? Другой вопрос: почему бы всегда вместо void возвращать *this/this? Вот в стандартной библиотеке есть цепочки методов, в том же std::string, но не все его методы возвращают *this. Какие будут соображения на этот счёт?

Другой вопрос: почему бы всегда вместо void возвращать *this/this?

В большинстве случаев это не нужно.

Meyer ★★★★★
()

Это называется fluent interface. Удобно, когда предполагается множественный вызов методов одного объекта. Или для создания DSL, можно в качестве примера посмотреть тот же JUnit или Mockito.

hippi90 ★★★★★
()

Мне нравится, как в растовых либах сделано: есть дублирующаяся по функциональности пара методов, обычный сеттер и цепочечный вариант:

pub fn set_scrollable(&mut self, scrollable: bool) {
    self.scrollable = scrollable;
}

// Chainable variant.
pub fn scrollable(self, scrollable: bool) -> Self {
    self.with(|s| s.set_scrollable(scrollable))
}
Но, чтобы избежать оверхэда, надо, наверное, отслеживать юзкейсы, когда цепочечные варианты реально часто нужны. Иначе они не особо нужны.

В Рубях, емнип, все методы возвращают ссылку на объект, т.е. подобный подход принят на уровне дизайна ЯП.

Virtuos86 ★★★★★
()

Мне нравится более явный первый вариант.

Второй вариант имеет следующие проблемы:

Foo *x;
x->someAction().AnotherAction(); // почему сначала ->, а потом .?

auto &z = Foo().someAction(); 
z.anotherAction(); // доступ к освобождённой памяти.

void f(Foo);
f(Foo()); // нет копирования.
f(Foo().someAction()); // есть копирование.
// но эта проблема решается перегрузкой всех методов с сигнатурой Foo && someAction() &&;

Тем не менее, в паттерне Builder это выглядит достаточно органично и тяжело использовать неверно.

vzzo ★★★
()
Последнее исправление: vzzo (всего исправлений: 2)
Ответ на: комментарий от hippi90

Это называется fluent interface.

Спасибо, я в курсе. И про Фоулера, который Мартин - тоже.

Удобно, когда предполагается множественный вызов методов одного объекта.

Ну, то есть, ты голосуешь за вариант №2?

azelipupenko
() автор топика
Ответ на: комментарий от vzzo

x->someAction().AnotherAction(); // почему сначала ->, а потом .?

Ну это разве проблема? Левый операнд - указатель. Обычное дело. Если хочется точечек, то *(x).someAction().anotherAction(). Если же копать ещё глубже, то если Foo имеет семантику указателей, то возвращать из someAction() и anotherAction() надо не *this, а this. И тогда будет x->someAction()->anotherAction(). Если же Foo имеет семантику значений, то и передаваться он будет по значению или по ссылке, и тогда будет x.someAction().anotherAction().

auto &z = Foo().someAction();
z.anotherAction(); // доступ к освобождённой памяти.

Да, но ведь можно и так стрельнуть в ногу:

auto& z = std::vector<int>().front();
z; // UB

но эта проблема решается перегрузкой всех методов с сигнатурой

Ну или же сигнатурой void f(Foo&&).

Тем не менее, в паттерне Builder это выглядит достаточно органично и тяжело использовать неверно.

Да вот в Qt тоже в основном «сеттеры» возвращают void. Но ведь в стандартной библиотеке же есть цепочки:

void f()
{
  std::cout << 1 << 2 << 3 << "\n";
  std::string s("1");
  s.append("2").append("3").append("\n");
}
В этом то и вопрос...

azelipupenko
() автор топика

Зависит от назначения класса и паттерна его использования. Если Foo короткоживущий сахр, т.е. можно делать как-то так

auto result = *Foo(bar).One(1).Two(2).Five();
то нужно возвращать *this для компактизации кода. И если работа с объектом требует разнообразных комбинаций методов для получения результата, то тоже можно возвращать *this в методах-действиях.

Другой вопрос: почему бы всегда вместо void возвращать *this/this?

В разработке всегда стоит придерживаться принципа чеховского ружья - т.е. если фича не имеет практического значения, то её не нужно делать.

Замена void на *this стоит около двух строчек кода на метод (т.е. делаешь boilerplate), делает методы с длинными названиями или с большим кол-вом аргументов менее читабельными в каскаде, может вводить читателя в заблуждение делая код не интуитивно понятным. Например, метод с std::abort() внутри

Foo& Abort();
хорошенько посношает мозги т.к. интерфейс предполагает что после вызова метода ещё возможны какие-то действия. Можно нарисовать аналогичный пример для обёртки над fd и метода Сlose().

Может быть существуют нюансы связанные с компиляторами и оверхедом от возвращения *this.

anonymous
()
Ответ на: комментарий от azelipupenko

Ну, то есть, ты голосуешь за вариант №2?

Я голосую за здравый смысл. Если данный подход в данном конкретном случае облегчает написание и чтение кода, то естественно я за.

hippi90 ★★★★★
()

Такой интерфейс создаёт ощущение, будто вызовы методов не меняют объект, на котором вызываются, а возвращают новый.

i-rinat ★★★★★
()
Ответ на: комментарий от anonymous

Например, метод с std::abort() внутри

Foo& Abort();
хорошенько посношает мозги т.к. интерфейс предполагает что после вызова метода ещё возможны какие-то действия. Можно нарисовать аналогичный пример для обёртки над fd и метода Сlose().

Так Abort() и Close() это и не сеттеры.

UPD: Хотя тут применимо не «сеттеры», а более общее «мутаторы». Вот из мутаторов можно (и нужно) возвращать ссылку, чтобы поддерживалась цепочка мутаций объекта (например - сеттинг полей, или что-то другое). Но Abort() и Close() я бы к мутаторам не отнёс.

Crocodoom ★★★★★
()
Последнее исправление: Crocodoom (всего исправлений: 1)

Для сетеров я думаю вполне вариант.

tyamur ★★
()

Второй вариант еще в случае наследования немного проигрывает:

struct Foo { Foo& bar(); }
struct Baz : Foo { Baz& boo(); }

Baz{}.bar()/* и все, теряем доступ к boo() */;

KennyMinigun ★★★★★
()
Последнее исправление: KennyMinigun (всего исправлений: 2)

Возвращать ссылку на себя удобно в случае например operator<< или operator>> - вот тут действительно удобно. А вот в других случаях, как по мне, вызов разных методов лучше разделять на отдельные операторы, чтобы не плодить километровых строк кода.

rumgot ★★★★★
()
Ответ на: комментарий от azelipupenko

если Foo имеет семантику указателей, то возвращать из someAction() и anotherAction() надо не *this, а this. И тогда будет x->someAction()->anotherAction().

К сожалению, в реальности не всё так просто. Во-первых, у людей каша в голове, и они периодически передают ссылки вместо указателей и наоборот. Типичный пример — какой-нибудь HttpServer, который создаётся в main() (возможно, на стеке), а потом гуляет по программе в виде указателя или константной ссылки. Предугадать, как коллега решит передать такой класс в свою функцию, практически невозможно. Во-вторых, при возвращении this теряется «умность» указателя, что тоже может привести к неочевидным проблемам, но, на самом деле, это та же самая проблема, что была в пункте 2:

for (auto i : *make_shared<Foo>()) // ok
for (auto i : *make_shared<Foo>()->Reverse()) // oops

Да, но ведь можно и так стрельнуть в ногу:

Вводимые интерфейсы должны скорее мешать стрелять в ногу, а не помогать. С void накосячить тяжело, а вот со ссылкой/указателем на непонятно где выделенный объект — легко.

P.S. на мой взгляд, интерфейс iostream — один из худших интерфейсов, которые можно было бы придумать, и является следствием не проектирования, а отсутствия в C++-до-11 variadic templates и попыток обойти это с какой-никакой типобезопасностью. Это ужасно выглядит в AST, это стреляет в ногу при использовании модификаторов (которые не отменяются после вывода одного элемента или конца оператора), это непонятно обрабатывает ошибки (написав cin >> a >> b >> c и получив в результате объект с fail, мы не знаем, сколько переменных было считано и какое влияние было оказано на курсор ввода). Но, к сожалению, по-другому было не сделать, пипл схавал, так что приходится терпеть и методично выпиливать все iostream в своих проектах в пользу libfmt.

Примерно то же самое можно сказать про QStringList() << "a" << "b" << "c" и initializer lists, но это тяжело назвать интерфейсом, и вокруг этого не настроено концепций, поэтому не так бесит.

vzzo ★★★
()
Ответ на: комментарий от vzzo

написав cin >> a >> b >> c и получив в результате объект с fail Можно делать ввод по очереди в разных командах и после каждой проверять на fail, если очень нужно.

rumgot ★★★★★
()
Ответ на: комментарий от anonymous

Это настолько странный вопрос, что я вместо ответа спрошу. Ты себе это как представляешь вообще?

i-rinat ★★★★★
()
Ответ на: комментарий от rumgot

Нельзя, потому что cin уже испортился, а мы не знаем, был ли он испорчен до этого, не смог считать a, b или c.

vzzo ★★★
()
Ответ на: комментарий от i-rinat

Да, очень давно так протупил с QDate. Я думал, что он имеет fluent interface, а он каждый раз возвращал новый объект:

QDate x(2018, 10, 12)
x.addMonths(2).addDays(3) // nothing happens with x

vzzo ★★★
()

Дело вкуса. Я бы к этому подошел как-то так:

struct A {
  void reset();
  void print() const;
  A & setX(int);
  A & setY(int);
};
  obj.setX(150).setY(200);
  obj.print();
  obj.reset();

А вот ребята из буста не побоялись проявить креативность:

        options_description desc("Allowed options");
        desc.add_options()
        // First parameter describes option name/short name
        // The second is parameter to option
        // The third is description
        ("help,h", "print usage message")
        ("output,o", value(&ofile), "pathname for output")
        ("macrofile,m", value(&macrofile), "full pathname of macro.h")
        ("two,t", bool_switch(&t_given), "preprocess both header and body")
        ("body,b", bool_switch(&b_given), "preprocess body in the header context")
        ("libmakfile,l", value(&libmakfile), "write include makefile for library")
        ("mainpackage,p", value(&mainpackage), "output dependency information")
        ("depends,d", value(&depends), "write dependencies to <pathname>")
        ("sources,s", value(&sources), "write source package list to <pathname>")
        ("root,r", value(&root), "treat <dirname> as project root directory")
        ;

https://www.boost.org/doc/libs/1_55_0/libs/program_options/example/real.cpp

driver
()
Ответ на: комментарий от vzzo

Так и праверяй на fail ДО, ВО ВРЕМЯ И ПОСЛЕ. Почему же не можем? С другой стороны, когда не требуется такая высокая детализация знания, в какой именно момент испортился cin, вполне нормально вводить из него данные по несколько переменных за раз.

rumgot ★★★★★
()
Ответ на: комментарий от vzzo

на мой взгляд, интерфейс iostream — один из худших интерфейсов, которые можно было бы придумать, и является следствием не проектирования, а отсутствия в C++-до-11 variadic templates и попыток обойти это с какой-никакой типобезопасностью. Это ужасно выглядит в AST, это стреляет в ногу при использовании модификаторов (которые не отменяются после вывода одного элемента или конца оператора) ... Но, к сожалению, по-другому было не сделать, пипл схавал, так что приходится терпеть и методично выпиливать все iostream в своих проектах в пользу libfmt.

Это однозначно косяк проектирования и чтобы его полечить не нужны variadic templates. libfmt тоже большой косяк проектирования являясь printf-ом для с++ сделанным си кодерами только начавшими изучать плюсы. А полечить можно простым выносом состояний форматирования в сахар:

std:cerr << "float: " << fmt::value(10.77, fmt_args, ...) << ...
и такой плюсовый подход намного удобнее убогих libfmt и прочих многочисленных попыток перенести printf один к одному в плюсы.

это непонятно обрабатывает ошибки (написав cin >> a >> b >> c и получив в результате объект с fail, мы не знаем, сколько переменных было считано и какое влияние было оказано на курсор ввода)

Проблема легко решается исключениями и аналогичными обёртками над операндами: можно сделать аналог std::optional или же внутри работать с общим состоянием считающим кол-во обработанных токенов из потока. Язык дал базовые элементы и как на них построить нужные фичи уже задача разработчика следующего слоя.

Основная проблема с iostream связана с реализацией потоков на темплейтах, хотя чаще нужно иметь абстрактный объект с виртуальными read(...) или write(...) в которые сахар и операторы << >> засовывают нужный контент - т.е. iostream как раз не является набором интерфейсов.

anonymous
()
Ответ на: комментарий от anonymous

Основная проблема с iostream связана с реализацией потоков на темплейтах

А до появления темплейтов проблемы не было?

чаще нужно иметь абстрактный объект с виртуальными read(...) или write(...) в которые сахар и операторы << >> засовывают нужный контент

Это streambuf.

tailgunner ★★★★★
()
Ответ на: комментарий от vzzo

Во-первых, у людей каша в голове, и они периодически передают ссылки вместо указателей и наоборот.

Это проблема этих людей. Язык оказался слишком сложным для них, а возможно и профессия - нужно срочно её менять.

Типичный пример — какой-нибудь HttpServer, который создаётся в main() (возможно, на стеке)

Начитаются дерьма, а потом эмулируют глобальные объекты через проброс. Это проблема явно другого характера.

Во-вторых, при возвращении this теряется «умность» указателя, что тоже может привести к неочевидным проблемам, но, на самом деле, это та же самая проблема, что была в пункте 2

Указатели не нужны, в данном случае. Да и вообще почти в любом другом, ты привёл изначально глупый пример. Проблема всё та же, некоторые эксперты перепутали кресты и жабку. В 90% случаев хип вообще не нужен, т.е. объекты создаются либо в контексте стека, структур, аргументов. Нет никакого смысла долбить new. Из остальных 10% - 90% так же не нуждаются в new, т.к. они хранятся в контексте какого-нибудь контейнера.

P.S. на мой взгляд, интерфейс iostream — один из худших интерфейсов, которые можно было бы придумать, и является следствием не проектирования, а отсутствия в C++-до-11 variadic templates и попыток обойти это с какой-никакой типобезопасностью.

Вариадики тебе вряд ли помогут, т.к. они целиком и полностью бесполезны. Нормальный статический printf ты всё равное не реализуешь. Даже сейчас это будет выглядеть просто убого.

это непонятно обрабатывает ошибки (написав cin >> a >> b >> c и получив в результате объект с fail, мы не знаем, сколько переменных было считано и какое влияние было оказано на курсор ввода).

Как ты себе представляешь это в printf?

Примерно то же самое можно сказать про QStringList() << «a» << «b» << «c» и initializer lists, но это тяжело назвать интерфейсом, и вокруг этого не настроено концепций, поэтому не так бесит.

Это не initializer lists - это append, который используют как initializer lists.

LjubaSherif
()
Ответ на: комментарий от anonymous

Тупой бред начитанного идиота.

anonymous
()
Ответ на: комментарий от i-rinat

Метод не может быть чистым по определению - он зависит от внешнего контекста, который может быть каким угодно.

Хочешь чистую функцию - отвяжи её от контекста, т.е. сделай статической. В противном случае она не чистая.

А по теме чистоты всё уже сказал и доказал мир ФП. Думаю, что если собрать всю сложность из мира фп, то её даже на хром не хватит. Вот такая она, клоака чистых функций с изоляцией сложности, которая сложности и не видела.

LjubaSherif
()
Ответ на: комментарий от LjubaSherif

Метод не может быть чистым по определению - он зависит от внешнего контекста, который может быть каким угодно.

Хочешь чистую функцию - отвяжи её от контекста, т.е. сделай статической. В противном случае она не чистая.

Ты ведь знаешь, что такое this в C++, ведь так?

i-rinat ★★★★★
()
Ответ на: комментарий от i-rinat

Ты ведь знаешь [...]

А тебе это правда интересно?

В любом случае, он не может тебе ответить. По крайней мере, здесь.

tailgunner ★★★★★
()
Ответ на: комментарий от tailgunner

Ты ведь знаешь [...]

А тебе это правда интересно?

Кажется, я понял, о чём ты.

В любом случае, он не может тебе ответить. По крайней мере, здесь.

ack

i-rinat ★★★★★
()

Foo* someAction();

Голосую за такое и только там, где это логично.

trex6 ★★★★★
()
Ответ на: комментарий от trex6

Такой интерфейс создаёт ощущение, будто вызовы методов не меняют объект, на котором вызываются, а возвращают новый.

Тогда методы были бы константными.

На месте вызова константности методов не видно.

i-rinat ★★★★★
()
Ответ на: комментарий от i-rinat

Я совсем не понял эту игру слов, но не хотелось бы чтобы тред вот так закончился.

Пользуясь моментов хотел бы спросить что будет вот в таком случае:

Foo * a = new Foo();
Foo b;

Foo& Foo::func1 {
    // Do something
    return this;
}

Синтаксис может быть не точный, но я думаю идея понятна. a создается на куче, b на стеке. Так вот если дальше в каких-либо контекстах будут вызовы:

a->func1();
b.func1();

то не возникнет ли каких-то проблем? Крэш приложения там или что-то вроде того.

Ну и если возникает такое, то как с этим борятся?

Kronick
()
Ответ на: комментарий от Kronick

то не возникнет ли каких-то проблем? Крэш приложения там или что-то вроде того.

В общем случае на такой вопрос нельзя ответить. В данном конкретном случае я не вижу проблем с кодом.

a создается на куче, b на стеке

При вызове методов туда передаётся ещё один неявный параметр, this. Это указатель на набор данных, специфичных для конкретного объекта. Если объект был создан на стеке, this указывает на стек. Если в куче — в кучу. Задача программиста — поддерживать этот указатель валидным. И вот если с этим налажать, будут проблемы.

Всё просто же.

i-rinat ★★★★★
()
Ответ на: комментарий от anonymous

Худшают анонимы :(

1) fmtlib не переводит printf 1-в-1 в плюсы. С натягом можно сказать, что она оставляет некоторое понятие форматной строки, но это удобнее, чем ручное дерганье formatDoubleWithPrecision() для каждого вывода.

2) Исключения не помогают узнать, в каком месте программы возникла ошибка, скорее даже мешают.

3) iostream и потоки абсолютно не связаны с темплейтами. ostream::operator<<(ostream&, T) — обычная функция, которая внутри себя рано или поздно дергает виртуальный член ostream для реального вывода.

vzzo ★★★
()
Ответ на: комментарий от rumgot

Так и праверяй на fail ДО, ВО ВРЕМЯ И ПОСЛЕ.

Так мы тут вроде fluent interface пытаемся обсудить?

vzzo ★★★
()
Ответ на: комментарий от LjubaSherif

Как ты себе представляешь это в printf?

А где я говорил, что printf для этой цели подходит? scanf хотя бы возвращает количество успешно считанных элементов одним вызовом.

vzzo ★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.