Делаем свой Pattern Matching в Dart

2022-10-31

Как известно, полноценного механизма сопоставления шаблонов в языке Dart нет, хотя разработчики обещают его завезти.

Однако, в своих задачах я часто сталкиваюсь с ситуацией, когда этого механизма сильно не хватает. Типичный пример - работа со всевозможными билдерами (BlocBuilder, ValueListenableBuilder и т.д.), когда в зависимости от состояния нужно вернуть один из нескольких виджетов:

...
BlocBuilder(
  bloc: bloc,
  builder: (context, state) {
    if (state is SomeClass) {
      return SomeWidget(...);
    } else if (state is OtherClass) {
      return OtherWidget(...);
    }
    ...
  }
),
...

Управляться с таким набором условий совсем невесело.

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

BlocBuilder(
  bloc: bloc,
  builder: (context, state) {
    switch (state.status) {
      case StatusEnum.someStatus:
        return SomeWidget(...);
      case StatusEnum.otherStatus:
        return OtherWidget(...);
    }
    ...
  }
),
...

Всё становится ещё хуже, например, если вы хотите организовать анимацию переключения между виджетами для каждого состояния:

builder: (context, state) {
  final Widget widget;
  if (state.hasError) {
    widget = ErrorWidget(...);
  } else if (state.isLoading) {
    widget = LoadingWidget(...);
  } else {
    widget = const SizedBox();
  }

  return AnimatedSwitcher(
    duration: duration,
    transitionBuilder: (child, animation) => FadeTransition(
      opacity: animation,
      child: child,
    ),
    child: widget,
  );
},

When - Case

Итак, давайте приступим к реализации механизма сопоставления шаблонов.

Общая идея такова: мы получаем коллекцию возможных вариантов, плюс вариант по-умолчанию (если ни оин шаблон не был сопоставлен); каждый вариант включает в себя условие сопоставления и результат заданного типа T, который будет возвращён при сопоставлении.

Для начала выразим идею варианта сопоставления в виде абстрактного класса AbstractCase:

abstract class AbstractCase<R> {
  bool get condition;
  R get result;

  const AbstractCase();
}

Теперь можно реализовать механизм сопоставления:

class When<R> {
  final Iterable<AbstractCase<R>> _cases;
  final R _defaultResult;

  R get match {
    for (final c in _cases) {
      if (c.condition) return c.result;
    }

    return _defaultResult;
  }

  const When(Iterable<AbstractCase<R>> cases, R defaultResult)
      : _cases = cases,
        _defaultResult = defaultResult;
}

Осталось добавить конкретные реализации AbstractCase (их будет две):

class Case<R> extends AbstractCase<R> {
  @override
  final bool condition;
  @override
  final R result;

  const Case(this.condition, this.result);
}

class LazyCase<R> extends AbstractCase<R> {
  final bool Function() _condition;
  final R Function() _result;

  @override
  bool get condition => _condition();

  @override
  R get result => _result();

  const LazyCase(bool Function() condition, R Function() result)
      : _condition = condition,
        _result = result;
}

Как использовать?

Рассмотрим на примере с AnimatedSwitcher.

В простейшем случае можно воспользоваться Case:

builder: (context, state) {
  final widget = When([
      Case(state.hasError, ErrorWidget(...)),
      Case(state.isLoading, LoadingWidget(...)),
    ],
    const SizedBox(),
  ).match;

  return AnimatedSwitcher(
    duration: duration,
    transitionBuilder: (child, animation) => FadeTransition(
      opacity: animation,
      child: child,
    ),
    child: widget,
  );
},

Правда у такого применения есть недостаток: все виджеты во втором аргументе Case будут построены, даже если ни одно из условий не будет выполнено. Чтобы этого избежать, можно добавить немного "ленивости" в наши варианты:

builder: (context, state) {
  final widget = When([
      Case(state.hasError, () => ErrorWidget(...)),
      Case(state.isLoading, () => LoadingWidget(...)),
    ],
    () => const SizedBox(),
  ).match;

  return AnimatedSwitcher(
    duration: duration,
    transitionBuilder: (child, animation) => FadeTransition(
      opacity: animation,
      child: child,
    ),
    child: widget.call(),
  );
},

Фактически, в данном случае каждый Case вернёт не сам виджет, а функцию-билдер. Благодаря этому мы можем не хранить в памяти все результаты, а получить только сопоставленный при необходимости (расходы на лямбда-функции считаем несущественными).

Следует заметить, что если вычисление значения условия в Case тоже оказывается вычислительно трудоёмкой задачей, то можно отложить его до момента сопоставления конкретного варианта. В этом нам поможет "ленивая" реализация LazyCase:

builder: (context, state) {
  final widget = When([
      LazyCase(() => state.errorCondition(), () => ErrorWidget(...)),
      LazyCase(() => state.loadingCondition(), () => LoadingWidget(...)),
    ],
    SizedBox(),
  ).match;

  return AnimatedSwitcher(
    duration: duration,
    transitionBuilder: (child, animation) => FadeTransition(
      opacity: animation,
      child: child,
    ),
    child: widget,
  );
},

Теперь паттерн-матчинг доступен и в Dart/Flutter, пользуйтесь на здоровье!