Перейти к содержанию

Builder (Строитель)

Категория: порождающий паттерн.

Проблема

Объект нужно собрать из большого числа частей, многие из которых опциональны, а порядок или сама сборка может требовать промежуточных шагов. Если попытаться выразить все комбинации через конструкторы, получится либо один конструктор с десятком параметров (часть из которых - null/значения по умолчанию для необязательных частей), либо "телескопические" перегрузки конструктора на каждую комбинацию параметров - оба варианта плохо читаются и плохо расширяются.

Решение

  1. Процесс сборки объекта выносится в отдельный класс - Builder.
  2. Builder предоставляет набор методов для пошаговой настройки будущего объекта, каждый из которых отвечает за одну часть (WithTitle, AddSection, ...).
  3. Финальный метод (Build()) собирает итоговый объект из накопленного состояния - часто неизменяемый (immutable), что удобно, так как продукт после сборки уже не должен меняться "снаружи".

Частая реализация в C# - fluent-интерфейс: каждый метод настройки возвращает this, что позволяет писать сборку одной цепочкой вызовов.

Структура

  • Product - собираемый сложный объект.
  • Builder - интерфейс/класс с методами пошаговой настройки и методом Build().
  • Director (опционально) - класс, знающий типовые последовательности вызовов builder'а для стандартных вариантов продукта; в C# часто опускается, так как порядок вызовов и так очевиден из кода клиента.

Варианты реализации

У Builder нет официального набора из «трёх» или «четырёх» отдельных подпаттернов. Ниже - распространённые формы одной идеи. Они отличаются строгостью, универсальностью и тем, когда клиент получает доступ к продукту.

1. Классический Builder с Director

Director знает последовательности шагов, но работает с общим интерфейсом Builder. Несколько конкретных строителей выполняют одинаковые шаги по-разному и могут создавать даже несвязанные продукты. Например, один строит дом, другой по тем же этапам формирует инструкцию по его строительству.

Этот вариант оправдан, когда типовые сценарии сборки переиспользуются, а представлений продукта действительно несколько. Если продукт один и алгоритм очевиден, интерфейс строителя и Director часто только усложняют код.

2. Упрощённый fluent-builder для одного продукта

Отдельный конкретный класс накапливает параметры и возвращает продукт из Build(). Каждый метод возвращает this, поэтому вызовы образуют читаемую цепочку. Именно этот практичный вариант показан в Builder.cs и чаще всего встречается в современном .NET.

3. Builder на методах расширения

Методы расширения могут придать существующему изменяемому типу fluent-синтаксис: new MailMessage().From(...).To(...). Это лёгкий вариант, но он манипулирует уже созданным продуктом: промежуточное невалидное состояние доступно клиенту, а builder не может надёжно скрыть его до финального Build().

4. Строго типизированный Step Builder

Каждый обязательный этап возвращает интерфейс следующего этапа, а Build() появляется только после выполнения всех обязательных шагов. Ошибка использования переносится из рантайма в компиляцию. Цена - дополнительные интерфейсы/типы и более тяжёлая поддержка; для большинства моделей достаточно проверки в Build() или обязательных аргументов конструктора builder'а.

5. Builder для неизменяемого продукта

Builder хранит изменяемое промежуточное состояние, а Build() создаёт immutable-объект только с геттерами. Часто builder делают вложенным типом продукта или предоставляют через ToBuilder(). Это удобно и может быть эффективнее последовательного создания множества промежуточных immutable-экземпляров.

Рабочие примеры всех форм находятся в BuilderVariants.cs: классический Director с двумя продуктами, методы расширения, Step Builder и вложенный builder неизменяемого объекта.

Когда применять

  • Объект имеет много полей, значительная часть которых опциональна.
  • Нужно гарантировать, что итоговый объект будет создан только в валидном, полностью настроенном состоянии (продукт неизменяем, а промежуточные, "недособранные" состояния наружу не видны).
  • Один и тот же процесс сборки должен уметь производить разные представления продукта.

Плюсы

  • Убирает "телескопические конструкторы" и делает код сборки объекта читаемым.
  • Позволяет собирать объект по шагам, в том числе с проверками на каждом шаге.
  • Итоговый продукт можно сделать полностью неизменяемым, скрыв изменяемое состояние внутри builder'а.

Минусы

  • Дополнительный класс и код ради, по сути, альтернативного способа вызвать конструктор - для простых объектов (2-3 обязательных поля) это избыточно.
  • В современном C# многие сценарии Builder закрываются именованными и опциональными параметрами конструктора, а также object initializer'ами (new Report { Title = ..., ... }), особенно если все поля - изменяемые свойства, а не readonly.

Отличие от Abstract Factory

Abstract Factory создаёт объект за один вызов одного из своих методов и обычно работает с семейством разных продуктов. Builder собирает один продукт пошагово, за несколько вызовов, и его основная ценность - именно в поэтапной настройке сложного объекта.

Пример в .NET Framework / BCL

  • StringBuilder - классический пример: Append, AppendLine, Insert пошагово формируют строку, ToString() - финальная сборка продукта.
  • HostBuilder/WebApplicationBuilder в ASP.NET Core - пошаговая настройка (ConfigureServices, ConfigureLogging, ...) с финальным Build().
  • LINQ-выражения вида IQueryable (например, в EF Core) - каждый вызов (Where, OrderBy, Select) добавляет часть будущего запроса, который "собирается" и выполняется в момент перечисления.

Источники для углубления

  • Тепляков С. «Паттерны проектирования на платформе .NET», глава 11: fluent interface, методы расширения, строго типизированный строитель и immutable-объекты.
  • Refactoring.Guru - Builder: классическая структура с несколькими строителями и опциональным Director.

Пример реализации на C#

Builder.cs
using System;
using System.Collections.Generic;

namespace DesignPatterns.Creational.Builder
{
    // Продукт - сложный неизменяемый объект со множеством опциональных частей
    public sealed class Report
    {
        public string Title { get; }
        public IReadOnlyList<string> Sections { get; }
        public bool HasSummary { get; }

        public Report(string title, IReadOnlyList<string> sections, bool hasSummary)
        {
            Title = title;
            Sections = sections;
            HasSummary = hasSummary;
        }

        public override string ToString() =>
            $"Отчёт «{Title}», разделов: {Sections.Count}, есть резюме: {HasSummary}";
    }

    // Строитель с fluent-интерфейсом: каждый метод возвращает this,
    // что позволяет собирать объект цепочкой вызовов.
    public sealed class ReportBuilder
    {
        private string _title = "Без названия";
        private readonly List<string> _sections = new();
        private bool _hasSummary;

        public ReportBuilder WithTitle(string title)
        {
            _title = title;
            return this;
        }

        public ReportBuilder AddSection(string section)
        {
            _sections.Add(section);
            return this;
        }

        public ReportBuilder WithSummary()
        {
            _hasSummary = true;
            return this;
        }

        // Финальный шаг - собираем неизменяемый продукт из накопленного состояния.
        public Report Build() => new(_title, _sections.AsReadOnly(), _hasSummary);
    }

    public static class Demo
    {
        public static void Run()
        {
            Report report = new ReportBuilder()
                .WithTitle("Продажи за квартал")
                .AddSection("Выручка по регионам")
                .AddSection("Топ-10 клиентов")
                .WithSummary()
                .Build();

            Console.WriteLine(report);
        }
    }
}
BuilderVariants.cs
using System;
using System.Collections.Generic;

namespace DesignPatterns.Creational.Builder.Variants
{
    // Вариант 1: классический Builder. Один Director выполняет одинаковые шаги,
    // а разные строители получают несвязанные продукты.
    public interface IHouseBuilder
    {
        void Reset();
        void BuildWalls();
        void BuildRoof();
        void BuildGarage();
    }

    public sealed class House
    {
        private readonly List<string> _parts = new();
        public IReadOnlyList<string> Parts => _parts.AsReadOnly();
        public void Add(string part) => _parts.Add(part);
    }

    public sealed class HouseBuilder : IHouseBuilder
    {
        private House _house = new();

        public void Reset() => _house = new House();
        public void BuildWalls() => _house.Add("Кирпичные стены");
        public void BuildRoof() => _house.Add("Металлическая крыша");
        public void BuildGarage() => _house.Add("Гараж");

        public House GetResult()
        {
            House result = _house;
            Reset();
            return result;
        }
    }

    public sealed class ConstructionPlan
    {
        private readonly List<string> _steps = new();
        public IReadOnlyList<string> Steps => _steps.AsReadOnly();
        public void Add(string step) => _steps.Add(step);
    }

    public sealed class ConstructionPlanBuilder : IHouseBuilder
    {
        private ConstructionPlan _plan = new();

        public void Reset() => _plan = new ConstructionPlan();
        public void BuildWalls() => _plan.Add("Раздел: расчёт стен");
        public void BuildRoof() => _plan.Add("Раздел: расчёт крыши");
        public void BuildGarage() => _plan.Add("Раздел: расчёт гаража");

        public ConstructionPlan GetResult()
        {
            ConstructionPlan result = _plan;
            Reset();
            return result;
        }
    }

    public sealed class HouseDirector
    {
        public void BuildMinimal(IHouseBuilder builder)
        {
            builder.Reset();
            builder.BuildWalls();
            builder.BuildRoof();
        }

        public void BuildWithGarage(IHouseBuilder builder)
        {
            BuildMinimal(builder);
            builder.BuildGarage();
        }
    }

    // Вариант 2: методы расширения дают fluent-синтаксис существующему
    // изменяемому продукту. Продукт при этом не скрыт до завершения сборки.
    public sealed class MailDraft
    {
        public string From { get; set; } = string.Empty;
        public string To { get; set; } = string.Empty;
        public string Subject { get; set; } = string.Empty;
    }

    public static class MailDraftBuilderExtensions
    {
        public static MailDraft FromAddress(this MailDraft draft, string address)
        {
            draft.From = address;
            return draft;
        }

        public static MailDraft ToAddress(this MailDraft draft, string address)
        {
            draft.To = address;
            return draft;
        }

        public static MailDraft WithSubject(this MailDraft draft, string subject)
        {
            draft.Subject = subject;
            return draft;
        }
    }

    // Вариант 3: строго типизированный (staged/step) Builder. Build появляется
    // только после обязательных шагов To и Subject.
    public interface IRecipientStage
    {
        ISubjectStage To(string address);
    }

    public interface ISubjectStage
    {
        IOptionalStage Subject(string subject);
    }

    public interface IOptionalStage
    {
        IOptionalStage Body(string body);
        StagedEmail Build();
    }

    public sealed class StagedEmail
    {
        internal StagedEmail(string to, string subject, string body)
        {
            To = to;
            Subject = subject;
            Body = body;
        }

        public string To { get; }
        public string Subject { get; }
        public string Body { get; }
    }

    public sealed class StagedEmailBuilder : IRecipientStage, ISubjectStage, IOptionalStage
    {
        private string _to = string.Empty;
        private string _subject = string.Empty;
        private string _body = string.Empty;

        private StagedEmailBuilder()
        {
        }

        public static IRecipientStage Create() => new StagedEmailBuilder();

        public ISubjectStage To(string address)
        {
            _to = address;
            return this;
        }

        public IOptionalStage Subject(string subject)
        {
            _subject = subject;
            return this;
        }

        public IOptionalStage Body(string body)
        {
            _body = body;
            return this;
        }

        public StagedEmail Build() => new(_to, _subject, _body);
    }

    // Вариант 4: вложенный Builder создаёт неизменяемый продукт.
    public sealed class ImmutableRequest
    {
        private ImmutableRequest(Builder builder)
        {
            Url = builder.Url;
            Timeout = builder.Timeout;
            UseCache = builder.UseCache;
        }

        public string Url { get; }
        public TimeSpan Timeout { get; }
        public bool UseCache { get; }

        public static Builder Create() => new();

        public sealed class Builder
        {
            internal string Url { get; private set; } = string.Empty;
            internal TimeSpan Timeout { get; private set; } = TimeSpan.FromSeconds(30);
            internal bool UseCache { get; private set; }

            public Builder To(string url)
            {
                Url = url;
                return this;
            }

            public Builder WithTimeout(TimeSpan timeout)
            {
                Timeout = timeout;
                return this;
            }

            public Builder Cached()
            {
                UseCache = true;
                return this;
            }

            public ImmutableRequest Build()
            {
                if (string.IsNullOrWhiteSpace(Url))
                    throw new InvalidOperationException("URL обязателен.");

                return new ImmutableRequest(this);
            }
        }
    }

    public static class VariantsDemo
    {
        public static void Run()
        {
            var director = new HouseDirector();
            var houseBuilder = new HouseBuilder();
            var planBuilder = new ConstructionPlanBuilder();
            director.BuildWithGarage(houseBuilder);
            director.BuildWithGarage(planBuilder);

            MailDraft draft = new MailDraft()
                .FromAddress("author@example.com")
                .ToAddress("reader@example.com")
                .WithSubject("Builder");

            StagedEmail email = StagedEmailBuilder.Create()
                .To("reader@example.com")
                .Subject("Обязательные шаги проверяет компилятор")
                .Body("Build недоступен до вызовов To и Subject")
                .Build();

            ImmutableRequest request = ImmutableRequest.Create()
                .To("https://example.com")
                .WithTimeout(TimeSpan.FromSeconds(5))
                .Cached()
                .Build();

            Console.WriteLine(houseBuilder.GetResult().Parts.Count);
            Console.WriteLine(planBuilder.GetResult().Steps.Count);
            Console.WriteLine(draft.Subject);
            Console.WriteLine(email.Subject);
            Console.WriteLine(request.Url);
        }
    }
}

Открыть Builder.cs отдельно Скачать Builder.cs

Открыть BuilderVariants.cs отдельно Скачать BuilderVariants.cs