Сейчас на небе только и разговоров, что о вайб-кодинге. А я, к своему стыду, даже парный никогда не пробовал. На разных местах подходил к руководству с предложениями потестировать методику, но ответом были неизменные шуточки за триста. Мол, конечно-конечно. Вам, сэр, на время Ваших чудачеств рабочее время удвоить или зарплату уполовинить? Но счастлив тот, кто может укрыться от подобного мрака в уютной и светлой внутренней мастерской своего инженерного гения.

Кто бы что ни говорил, но снижение затрат на разработку и поддержку в долгосрочной перспективе через повышение качества кода и прочие обычно приводимые преимущества парного программирования - для бизнеса материи настолько тонкие, что неотличимы от воздуха. А чтобы продать бизнесу воздух, нужны качества совершенно иного рода, отнюдь не инженерного. Другое дело - оплачиваемые по известному тарифу человеко-часы. Их и смоделировать, не впадая в соблазн натянуть сову на глобус, гораздо проще. Займемся же этим, вооружившись C# и пакетом MathNet.Numerics (или любым другим языком и библиотеками).

Пусть существует некоторое количество навыков, из которых с одинаковой вероятностью можно выбрать несколько случайных. Также пусть существуют задачи, сложность которых определяется количеством требуемых для их решения навыков и приблизительно подчиняется закону нормального распределения.

public class Model(int skills, int compexity, int dev)
{
    private readonly IReadOnlyCollection<int> skills =
        [.. Enumerable.Range(1, skills)];
    private readonly int min = compexity - 3 * dev;
    private readonly int max = compexity + 3 * dev;
    private readonly Normal distribution = new(compexity, dev);

    public int[] PickSkills(int count) =>
        [.. skills.SelectVariation(count)];

    public int PickComplexity() =>
        distribution.Samples()
        .Select(i => (int)Math.Round(i))
        .First(i => i >= min && i <= max);
}
Библиотека сторонняя, поэтому я бы написал тесты
[Theory]
[InlineData(2, 2)]
[InlineData(5, 5)]
[InlineData(5, 2)]
public void PickSkills_HasUniformDistribution(int total, int picked)
{
    // Arrange
    Model sut = CreateSut(skills: total);

    // Act
    int[][] data = [.. Enumerable.Range(0, (int)1e+6)
        .Select(_ => sut.PickSkills(picked))];

    // Assert
    double expected = 1d / total;
    (int val, int pos, double frequency)[] frequencies =
        [.. Enumerable.Range(1, total).SelectMany(
            _ => Enumerable.Range(0, picked),
            (val, pos) => (val, pos, GetFrequency(val, pos)))];
  
    Assert.All(frequencies, actual =>
        Assert.True(Math.Abs(actual.frequency - expected) < 5e-3));

    double GetFrequency(int val, int pos) =>
        ((double)data.Count(skills => skills[pos] == val)) / data.Length;
}

[Fact]
public void PickComplexity_HasNormalDistribution()
{
    // Arrange
    const double complexity = 4;
    const double dev = 1;

    Model sut = CreateSut(complexity: (int)complexity, dev: (int)dev);

    // Act
    int[] data = [.. Enumerable.Range(0, (int)1e+6)
        .Select(_ => sut.PickComplexity())];

    // Assert
    (int val, double p)[] expected = [.. new[]{ 1, 2, 3, (int)complexity, 5, 6, 7 }
        .Select(i => (i, GetProbability(i)))];
    (int val, double p)[] actual = [.. data.GroupBy(i => i).OrderBy(i => i.Key)
        .Select(i => (i.Key, (double)i.Count() / data.Length))];
  
    Assert.Equal(expected, actual, (i, j) =>
        i.val == j.val && Math.Abs(i.p - j.p) < 2e-2);

    static double GetProbability(double x) =>
        Math.Exp(-Math.Pow((x - complexity) / dev, 2) / 2) /
        (Math.Sqrt(2 * Math.PI) * dev);
}

private static Model CreateSut(int skills = 0, int complexity = 0, int dev = 0) =>
    new(skills, complexity, dev);

Пусть стоимость решения задачи определяется следующим образом: за каждый необходимый навык, если он имеется в наличии, начисляется 1 балл, в противном же случае цена равна штрафу (эквиваленту дополнительного времени на курение мануалов, поиски в интернете и расспрашивание коллег при решении нетривиальных задач).

public class Model(int skills, int compexity, int dev, int fine)
{
    // ...
    private readonly int fine = fine;

    public int GetCost(HashSet<int> required, HashSet<int> available) =>
        required.Sum(i => available.Contains(i) ? 1 : fine);

    // ...
}
Не бином Ньютона, но тоже лучше протестировать
[Theory]
[InlineData(new[] { 1, 2, 3 }, new[] { 1, 2 }, 2, 4)]
[InlineData(new[] { 1, 2 }, new[] { 1, 2, 3 }, 2, 2)]
public void GetCost_IsOK(int[] required, int[] available, int fine, int expected)
{
    // Arrange
    Model sut = CreateSut(fine: fine);

    // Act
    int actual = sut.GetCost([.. required], [.. available]);

    // Assert
    Assert.Equal(expected, actual);
}

private static Model CreateSut(int skills = 0, int complexity = 0, int dev = 0, int fine = 0) =>
    new(skills, complexity, dev, fine);

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

[Fact]
public void Sum_ThrowsOverflowException() =>
    Assert.Throws<OverflowException>(() =>
        new[] { int.MaxValue, 1 }.Sum());

Пусть для простоты два разработчика обладают одинаковым количеством навыков, характеризующих их умение решать стандартные задачи. Сами навыки могут случайным образом различаться, но множество их значений ограничено. Тогда можно взять некоторое количество случайных задач (backlog) и посчитать отношение трудозатрат при парной и одиночной разработке для различного сочетания умения решать стандартные задачи и сложностей при решении нестандартных задач. Имеет смысл сделать несколько итераций и усреднить полученные значения.

const int complexity = 4;
const int dev = 1;
const int maxSkills = 10;
const int maxFine = 8;
const int backlog = 1000;
const int iterations = 1000;
(int skills, int fine, double efficiency)[] data = [..
    Enumerable.Range(1, maxSkills).Append(0)
    .SelectMany(
        _ => Enumerable.Range(1, maxFine),
        (skills, fine) =>
        {
            Model model = new(maxSkills, complexity, dev, fine);
            double efficiency = Enumerable.Range(default, iterations).Average(_ =>
            {
                HashSet<int> single = [.. model.PickSkills(skills)];
                HashSet<int> pair = [.. single.Concat(model.PickSkills(skills))];
                IEnumerable<HashSet<int>> tasks = Enumerable.Range(default, backlog)
                    .Select(_ => new HashSet<int>(model.PickSkills(model.PickComplexity())));

                return 2d * GetCost(pair) / GetCost(single);

                int GetCost(HashSet<int> available) =>
                    tasks.Sum(task => model.GetCost(task, available));
            });

            return (skills, fine, efficiency);
        })];

Результаты можно интерпретировать так: если уровень котов, которых Вы пасете, в среднем ниже среднего, или же решаемые задачи не претендуют хоть сколько-нибудь на статус интеллектуального вызова - парное программирование убыточно. А вот если они круты, но не совсем рок-звезды, и решают экстраординарные задачи, то работа в парах как минимум безубыточна. Бесплатно впридачу идет ускорение обмена опытом и повышение сплочености без лишних митапов и тим-билдингов (даже на чтение внутренних вики меньше времени будет уходить).

На самом деле, моделька не очень-то продает идею ПП. Хотелось бы, чтобы оно не просто не уступало одиночному, при этом только в исключительных случаях, но превосходило его. Можно, например, предположить некую синергию:

  • Решая одну задачу на двоих, разработчики делят пополам проблематичные аспекты

  • Работая в паре, каждый старается чуть больше из-за соревновательного элемента

  • Вдвоем просто веселее, и поэтому работа идет быстрее

Если работа вдвоем эффективнее более чем хотя бы на 5%, обнаруживается потенциальная выгода ПП

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

Наблюдения:

  • Без каких-либо таинственных синергий или иных волшебных вспомогательных сущностей возникает островок превосходства ПП

  • Проявив небольшую наблюдательность, можно заметить, что первая точка паритета всегда возникает в строке №9 (т.е., грубо говоря, когда стандартная задача решается за час, а нестандартная более чем за день) и является минимумом в своей строке

  • Проявив чуть больше внимательности, можно обнаружить, что каждое первое появление значения в качестве локального минимума происходит в одной и той же строке

  • А выкрутив ручку проницательности на максимум и глядя на неокругленные значения, получится увидеть, что локальный минимум строки всегда занимает одинаковое положение относительно ее длины

И тут впервые с начала исследования меня терзают смутные сомнения. Но пока разберемся со "сложностью" задач int Model.PickComplexity(), которая, если подумать, скорее является степенью декомпозиции. Как влияет хорошая (PickComplexity() => 1;) или плохая (PickComplexity() => 7;) декомпозиция на результат?

Оказывается, что никак

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

При определении эффективности парного программирования (E) отношение сумм стоимостей решения задач при парной и одиночной разработке можно заменить отношением математических ожиданий соответствующих стоимостей навыка, необходимого для решения задачи.

Вероятность отсутствия требуемого навыка у разработчика, обладающего N навыками из N_{max} возможных p=1-\frac{N}{N_{max}}\; (1). Тогда:

E = 2\frac{\mathbb{E}(C_{II})}{\mathbb{E}(C_{I})}=2\frac{(1-p^2)*1+p^2F}{(1-p)*1+pF}=2\frac{p^2(F-1)+1}{p(F-1)+1} \;(2)

Или в виде кода:

const int maxSkills = 40;
const int maxFine = 12;
(int skills, int fine, double efficiency)[] data = [..
    Enumerable.Range(1, maxSkills).Append(0)
    .SelectMany(
        _ => Enumerable.Range(1, maxFine),
        (skills, fine) =>
        {
            double p = 1d - (double)skills / maxSkills;
            double f = fine - 1d;
            double efficiency = 2d * (p * p * f + 1d) / (p * f + 1d);

            return (skills, fine, efficiency);
        })];
Вычисленные значения совпадают со смоделированными
Теперь, когда значения вычисляются практически мгновенно, можно окинуть взором почти сколь угодно большое множество ситуаций

Анализ формулы (2) приводит к обнаружению закономерностей, указанных в наблюдениях. Например, приравняв E = 1 и выразив F через p можно доказать первое появление паритета при F=9 и N=\frac{3}{4}N_{max}. Или можно показать, что для N = \frac{1}{2}N_{max} E\rightarrow1 при F\rightarrow\infty, т. е. для среднего уровня (и ниже) парное программирование всегда убыточно.

Но вернемся к брутфорсному подходу. Вместо

double efficiency = Enumerable.Range(default, iterations).Average(_ =>
{
    // ...
    return 2d * GetCost(pair) / GetCost(single);

    int GetCost(HashSet<int> available) =>
        tasks.Sum(task => model.GetCost(task, available));
});

return (skills, fine, efficiency);

можно было написать

double efficiency = Enumerable.Range(default, iterations).Average(_ =>
{
    // ...
    return 2d * GetCost(pair) / GetCost(single);

    // Sum -> Average
    double GetCost(HashSet<int> available) =>
        tasks.Average(task => model.GetCost(task, available));
});

return (skills, fine, efficiency);

По смыслу оба куска делают одно и то же. Но первый потенциально менее подвержен влиянию точности хранения чисел и интуитивно лучше соответствует идее долгосрочности. Зато второй более явно намекает на использование матожиданий.

Под капотом

На целочисленных типах Average внутри себя не накапливает ошибку точности. Она появляется единожды в самом конце при делении суммы на количество. Но никогда не стоит полагаться на внутреннюю реализацию чужого кода, или надо хотя бы писать какие-то тесты, сигнализирующие о противоречащих нашим ожиданиям изменениях.

public static double Average<TSource>(
	this IEnumerable<TSource> source, Func<TSource, int> selector) =>
	Average<TSource, int, long, double>(source, selector);

private static TResult Average<TSource, TSelector, TAccumulator, TResult>(
	this IEnumerable<TSource> source, Func<TSource, TSelector> selector)
    where TSelector : struct, INumber<TSelector>
    where TAccumulator : struct, INumber<TAccumulator>
    where TResult : struct, INumber<TResult>
{
    if (source is null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    }

    if (selector is null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.selector);
    }

    using (IEnumerator<TSource> e = source.GetEnumerator())
    {
        if (!e.MoveNext())
        {
            ThrowHelper.ThrowNoElementsException();
        }

        TAccumulator sum = TAccumulator.CreateChecked(selector(e.Current));
        long count = 1;

        while (e.MoveNext())
        {
            checked { sum += TAccumulator.CreateChecked(selector(e.Current)); }
            count++;
        }

        return TResult.CreateChecked(sum) / TResult.CreateChecked(count);
    }
}

Если поменять местами осреднение и деление, то получится эффективность ПП в краткосрочной перспективе E_{st}.

double efficiency = Enumerable.Range(default, iterations).Average(_ =>
{
    // ...
    return tasks.Average(task =>
    {
        return 2d * GetCost(pair) / GetCost(single);

        double GetCost(HashSet<int> available) => model.GetCost(task, available);
    });
});

return (skills, fine, efficiency);

Аналитически она равна удвоенному матожиданию отношения стоимости необходимого для решения задачи навыка при парной и одиночной разработке:

E_{st} = 2{\mathbb{E}(\frac{C_{II}}{C_{I}})}=2((1-p+p^2)*1+p(1-p)\frac{1}{F}) =2p(p-1)(1-\frac{1}{F})+2\;(3)
const int maxSkills = 40;
const int maxFine = 12;
(int skills, int fine, double efficiency)[] data = [..
    Enumerable.Range(1, maxSkills).Append(0)
    .SelectMany(
        _ => Enumerable.Range(1, maxFine),
        (skills, fine) =>
        {
            double p = 1d - (double)skills / maxSkills;
            double f = 1d / fine;
            double efficiency = 2d * p * (p - 1) * (1 - f) + 2;

            return (skills, fine, efficiency);
        })];

Невооруженным глазом видно, что в краткосрочной перспективе парное программирование неэффективно. Из аналитического решения можно вывести, что минимальное значение E_{st}\rightarrow1,5 при N = \frac{1}{2}N_{max} и F\rightarrow\infty. Т. е., применив ПП к одной единственной задаче, мы скорее всего окажемся в ситуации отца единственного сына из старого анекдота.

Выводы:

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

  • А я бы пришел к этому результату гораздо-гораздо быстрее, если бы работал в паре с математиком, совершенно не умеющим программировать

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

Gif'ка с котиками для дочитавших до конца

Комментарии (0)


  1. Pusk1
    23.09.2025 10:38

    Думаю, что есть проблема в самой модели. Начиная со стоимости задачи, которая в реальном мире часто декомпозируется и разделяется по исполнителям, которые обладают нужными знаниями.
    Парно кодить это не только сложение компетенций по направлениям (например, GO и SQL), но более долгая концентрация, возможность обсудить что-то с тем, кто всегда в контексте. Ещё не обязательно иметь одинаковый грейд. Меня, например, работа с джунами отлично бустит и их, я надеюсь, тоже.