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

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

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

Название скрытой выгоды

Когда мы начинаем писать меньшие функции, мы обнаруживаем скрытую выгоду от большего количества имен. Поскольку мы пишем больше функций, нам нужно создать больше имен. Поскольку у каждой функции должно быть имя, описывающее, что она делает, в нашем коде больше имен. Если мы будем тщательно называть наши функции, у нас теперь будет дорожная карта, описывающая путь через наш код. Это позволяет нам гораздо яснее рассуждать о том, что делает наш код и как он это делает. Потому что добавление большего количества имен заставляет нас отделять what от how.

Чтобы продемонстрировать разницу, я начал испытание алгоритма, в котором используется подход с одной большой длинной функцией. Во-первых, позвольте мне представить проблему. В качестве входных данных нам даны два массива. Один представляет собой массив целых чисел, представляющих coins определенного значения. Другой представляет собой массив равной длины, представляющий quantity монеты каждой ценности, которая у нас есть. Наша задача — вернуть количество возможных сумм, которые мы можем составить, учитывая информацию в обоих массивах.

Вот ограничения для наших входов:

1 ≤ coins.length ≤ 20,
1 ≤ coins[i] ≤ 10000
quantity.length = coins.length,
1 ≤ quantity[i] ≤ 100000
We are also guaranteed that: (quantity[0] + 1) * (quantity[1] + 1) * … * (quantity[quantity.length — 1] + 1) <= 1000000

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

function possibleSums(coins, quantity) {
    var coinValues = quantity.reduce(function(values, q, index){
        if(q > 1) {
            var newArr = new Array(q-1).fill(coins[index]);
            values = values.slice(0, index).concat(newArr).concat(values.slice(index));
        }
        return values;
    },coins);
    console.log('coinValues:', coinValues);
    var sums = coinValues.reduce(function(obj, coin){
      if (!obj.hasOwnProperty(coin)){
        obj[coin] = true;
      }
      return obj;
    },{});
    console.log('sums:', sums);
    for (var i = 0 ; i < coinValues.length ; i++) {
      for (var j = i + 1 ; j < coinValues.length; j++ ) {
        var sum = coinValues[i] + coinValues[j];
        if(!sums.hasOwnProperty(sum)) {
          sums[sum] = true;
        }
      }
    }
    for (var i = 0 ; i < coinValues.length ; i++) {
      for (var j = i + 2 ; j < coinValues.length; j++) {
        var sum = coinValues[i] + coinValues[i+1] + coinValues[j];
        if(!sums.hasOwnProperty(sum)) {
          sums[sum] = true;
        }
      }
    }
    for (var i = 0 ; i < coinValues.length ; i++) {
      for (var j = i + 3 ; j < coinValues.length; j++) {
        var sum = coinValues.slice(i,j+1).reduce(function(a,b){return a+b});
        if(!sums.hasOwnProperty(sum)) {
          sums[sum] = true;
        }
      }
    }
    return Object.keys(sums).length;
}

Основная цель состоит в том, чтобы хранить каждую уникальную сумму в качестве ключа в объекте sums. По сути, это жестко закодированное решение для одного из более коротких тестовых случаев. Это будет работать, если массивы монет и количества имеют длину всего 3 элемента. После этого становится понятно, что нам на самом деле нужен еще один цикл.

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

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

function possibleSums(coins, quantity) {
  var hasSum = {};
  coins.reduce(function(possibilities, coin, index){
    var newSums = [];
    var coinCombos = [];
    for (var i = 0 ; i <= quantity[index] ; i++) {
      coinCombos.push(coin * i);
    }
    possibilities.forEach(function(previousSum, index){
      for(var i = 0 ; i < coinCombos.length; i++) {
        var thisSum = coinCombos[i] + previousSum;
        if(!hasSum.hasOwnProperty(thisSum)){
          hasSum[thisSum] = true;
          newSums.push(thisSum);
        }
      }
    });
    possibilities = possibilities.concat(newSums);
    return possibilities;
  },[0]);
  
  return Object.keys(hasSum).length - 1;
}

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

Извлечение меньших функций

Во-первых, давайте возьмем функцию, которая создает массив возможных сумм, которые мы можем получить, выбрав другое количество для одной монеты. Этот массив был создан внутри нашего обратного вызова coins.reduce (строки 5-8 выше) и использовался внутри вложенного обратного вызова possibilities.forEach (строки 10-11 выше). Кроме того, это простой шаблон, использующий цикл for для добавления к массиву, который занимает дополнительное место в нашей функции. Вытягивание его в меньшую функцию перенесет фокус на более высокий уровень, где мы сможем увидеть, как части работают вместе.

function possibleSums(coins, quantity) {
  var hasSum = {};
  coins.reduce(function(possibilities, coin, index){
    var newSums = [];
    var coinCombos = getPossibleSumsForCoin(coin, quantity[index]);
    possibilities.forEach(function(previousSum, index){
      for(var i = 0 ; i < coinCombos.length; i++) {
        var thisSum = coinCombos[i] + previousSum;
        if(!hasSum.hasOwnProperty(thisSum)){
          hasSum[thisSum] = true;
          newSums.push(thisSum);
        }
      }
    });
    possibilities = possibilities.concat(newSums);
    return possibilities;
  },[0]);
  
  return Object.keys(hasSum).length - 1;
}
function getPossibleSumsForCoin(coin, quantity){
  var coinCombos = [];
  for (var i = 0 ; i <= quantity; i++) {
    coinCombos.push(coin * i);
  }
  return coinCombos;
}

Обратите внимание, что читабельность алгоритма значительно улучшилась после замены цикла for функцией getPossibleSumsForCoin. Намного яснее, что делал цикл for, теперь, когда он заменен ссылкой на описательно названную функцию. Мы отделили what от how.

Использование замыкания для подсчета возможных сумм

Далее мне не терпелось отделить постоянство от возможных сумм. Для этого я создал функцию UniqueSumCounter, которая создает замыкание над объектом sums, содержащим все возможные суммы, встречавшиеся до сих пор. Функция возвращает объект, который отвечает на две функции, add(sum) и total(). Это позволяет аккуратно инкапсулировать добавление и получение новых возможных сумм. Чтобы иметь дополнительное значение в нашем потоке управления, add(sum) вернет false, если сумма уже была записана.

function possibleSums(coins, quantity) {
  var sums = UniqueSumCounter();
  coins.reduce(function(possibilities, coin, index){
    var newSums = [];
    var coinCombos = getPossibleSumsForCoin(coin, quantity[index]);
    possibilities.forEach(function(previousSum, index){
      for(var i = 0 ; i < coinCombos.length; i++) {
        var thisSum = coinCombos[i] + previousSum;
        if(sums.add(thisSum)){
          newSums.push(thisSum);
        }
      }
    });
    possibilities = possibilities.concat(newSums);
    return possibilities;
  },[0]);
  
  return sums.total();
}
function getPossibleSumsForCoin(coin, quantity){
  var coinCombos = [];
  for (var i = 0 ; i <= quantity; i++) {
    coinCombos.push(coin * i);
  }
  return coinCombos;
}
function UniqueSumCounter() {
  var sums = {};
  
  return {
    add: add, 
    total: total
  };
  
  function add(sum) {
    if (!sums.hasOwnProperty(sum)) {
      sums[sum] = true;
      return true;
    } 
    return false;
  }
  
  function total() {
    return Object.keys(sums).length - 1;
  }
}
console.log(possibleSums([10,50,100,500],[5,3,2,2])); // => 122
console.log(possibleSums([10,50,100],[1,2,1])) // => 9
console.log(possibleSums([1],[5])) // => 5

Обратите внимание, как наша функция possibleSums стала более читабельной. Теперь мы можем просмотреть его и лучше понять, что он делает, прежде чем мы перейдем к тому, как он это делает. Извлекая меньшие функции и используя больше имен, мы сделали наш код более легким для чтения, отслеживания и понимания. Конечно, при таком подходе есть компромисс, так как теперь у нас почти в два раза больше строк кода. Разница в том, что наш код намного проще для понимания.

Тем не менее, большая часть нашей функции possibleSums по-прежнему говорит как, а не просто что. Внутри обратного вызова coins.reduce у нас есть цикл forEach, который содержит вложенный цикл for.

Эта часть кода отвечает за сбор новых возможных сумм, которые мы можем создавать каждый раз, когда добавляем еще одну монету. Он делает это, перебирая все предыдущие возможности суммирования и добавляя каждую из сумм, которые мы можем создать с помощью текущей монеты. Если сумма еще не сохранена в памяти как возможная сумма, то мы добавляем ее в наш массив newSums. Когда мы закончим просмотр всех новых возможностей, мы объединим массив newSums с массивом возможных сумм (possibilities) и перейдем к следующей монете.

Меньшие функции помогают нам ясно говорить о намерениях

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

*possibilities: массив возможных сумм, созданный комбинациями предыдущих монет
*currentCoinSums: массив возможных сумм, созданный путем выбора другой суммы текущей монеты.
*sums: пример замыкания UniqueSumCounter() который отслеживает возможные суммы в памяти.

Поскольку нам нужен доступ к sums, имеет смысл добавить новую функцию в наш объект UniqueSumCounter(), чтобы нам не пришлось передавать ее в качестве параметра:

Обратите внимание, насколько более явным становится possibleSums после извлечения этой функции:

function possibleSums(coins, quantity) {
  var sums = UniqueSumCounter();
  coins.reduce(function(possibilities, coin, index){
    var currentCoinSums = getPossibleSumsForCoin(coin, quantity[index]);
    var newSums = sums.combinePossibleSums(possibilities, currentCoinSums);
    possibilities = possibilities.concat(newSums);
    return possibilities;
  },[0]);
  
  return sums.total();
}
function getPossibleSumsForCoin(coin, quantity){
  var coinCombos = [];
  for (var i = 0 ; i <= quantity; i++) {
    coinCombos.push(coin * i);
  }
  return coinCombos;
}
function UniqueSumCounter() {
  var sums = {};
  
  return {
    add: add, 
    total: total,
    combinePossibleSums: combinePossibleSums
  };
  
  function add(sum) {
    if (!sums.hasOwnProperty(sum)) {
      sums[sum] = true;
      return true;
    } 
    return false;
  }
  
  function total() {
    return Object.keys(sums).length - 1;
  }
  
  function combinePossibleSums(previousSums, currentSums) {
    return previousSums.reduce(function(newSums, previousSum){
      currentSums.forEach(function(currentSum){
        var sum = previousSum + currentSum;
        if(add(sum)) {
          newSums.push(sum);
        }
      });
      return newSums;
    },[]);
  }
}
console.log(possibleSums([10,50,100,500],[5,3,2,2])); // => 122
console.log(possibleSums([10,50,100],[1,2,1])) // => 9
console.log(possibleSums([1],[5])) // => 5

Одним из очевидных недостатков здесь является то, что код на самом деле стал более фрагментированным. В процессе рефакторинга решения я понял, что на самом деле было бы проще инкапсулировать все эти более мелкие функции внутри определения функции possibleSums. Таким образом, sums, coins и quantity доступны для дочерних функций. Это пока что моя любимая итерация, потому что за ней проще всего следить, и она требует меньше определений переменных и прыжков с глазами, чтобы следовать за ней. Кроме того, это решает одну из проблем, которые беспокоили меня в предыдущем решении. А именно, что у меня был массив возможностей, а также объект, хранящий возможности. Это было и пустой тратой памяти, и потенциальным источником путаницы. Итак, я доволен следующим решением:

function possibleSums(coins,quantity) {
  var sums = {'0': true};
  
  return calculate();
  
  function calculate() {
    coins.forEach(function(coin,index){
      var currentCoinSums = getPossibleSumsForCoin(coin, quantity[index]);
      combinePossibleSums(currentCoinSums);
    });
    return total();
  }
  
  function getPossibleSumsForCoin(coin, quantity){
    var coinCombos = [];
    for (var i = 0 ; i <= quantity; i++) {
      coinCombos.push(coin * i);
    }
    return coinCombos;
  }
  
  function combinePossibleSums(currentSums) {
    return Object.keys(sums).forEach(function(previousSum){
      currentSums.forEach(function(currentSum){
        var sum = Number(previousSum) + currentSum;
        add(sum);
      });
    });
  }
  
  function add(sum) {
    if (!sums.hasOwnProperty(sum)) {
      sums[sum] = true;
    } 
  }
  
  function total() {
    return Object.keys(sums).length - 1;
  }
  
}

Одна вещь, которую это последнее решение действительно хорошо иллюстрирует, — это возможности замыкания для реализации объектно-ориентированного программирования в JavaScript. Прелесть объектно-ориентированного программирования в JavaScript заключается в том, что вместо того, чтобы создавать класс, а затем создавать экземпляры для решений, мы можем просто вызвать функцию для получения объекта решения. Например, было бы тривиальным изменением настроить возвращаемое значение функции так, чтобы оно было объектом, а не одним значением. Затем мы могли бы также предоставить окончательное значение sums, возможно, в форме массива, используя Object.keys(sums). Таким образом, у нас могут быть объекты-решения, которые не только сообщают нам, сколько возможных сумм мы можем получить, но также и все возможные варианты!

Последние мысли

Очевидно, что при извлечении меньших функций из длинной функции необходимо учитывать компромисс. Мы начинаем с одной функции с 21 строкой в ​​блоке и заканчиваем одной родительской функцией с пятью дочерними функциями, каждая из которых имеет длину менее 6 строк. Хотя все это в два раза длиннее, мне кажется, что следовать логике бесконечно проще. Извлечение функций позволяет однократно прочитать possibleSums, чтобы получить общее представление о том, как работает решение. Секрет в том, что имена этих небольших функций позволяют вам объяснить, что делает ваш код в процессе работы!

Первоначально опубликовано в разделе Стать программистом.