Статьи

Еще один подход к алмазному ката

Я видел, что Алистер Кокберн написал пост о посте Себа Роуза на Алмазном Ката. Я прочитал только начало обоих из них, потому что я узнал проблему, описанную Себом с помощью подхода «гориллы», которая достигла случая «С».

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

Я сталкивался с такими ситуациями и раньше, и для меня это всегда ключ к резервному копированию и работе небольшими шагами. Себ описывает, что случай «B» «достаточно прост, чтобы передать это путем жесткого кодирования результата». Алистер описывает стратегию как «немного перемешать» для дела «Б». Я не уверен, что означает «немного перетасовывать», и я не думаю, что было бы особенно легко заставить случаи «А» и «В» работать с константами и не сворачивать глупую « if (letter == 'A') … elseif (letter == 'B') …» реализацию. Мне было любопытно, как я подхожу к этому, и решил попробовать. ( Рон Джеффрис также написал пост на эту тему.) Я не читал ни одно из этих трех решений, прежде чем реализовывать свое собственное, просто чтобы посмотреть, что я буду делать.

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

Шаг 1: Убедитесь, что rspec работает

Как и Рон, я часто сначала пишу фиктивный тест, чтобы убедиться, что все правильно подключено. Это было особенно верно, так как я давно не программировал на Ruby, так долго, что я не работал с Ruby 2 или Rspec 3. Казалось, пришло время познакомиться с ними.

require 'rspec'

describe "setup" do
  it "can call rspec" do
  expect(2).to eql(2)
  end
end

Конечно, у меня изначально было « expect(1).to eql(2)», чтобы сделать тест неудачным. После того, как я получил правильный синтаксис и правильную установку, я провел неудачный тест, а затем изменил его, чтобы он прошел.

Шаг 2: Тривиальное представление вырожденного алмаза

Теперь я начинаю всерьез, заботясь о тривиальном деле.

describe Diamond do
  describe '.create(A)' do
  subject { Diamond.create('A') }

  it "has a trivial representation" do
  expect(subject.representation).to eql "A\n"
  end
  end
end

достигается с

class Diamond
  def self.create(max_letter)
  Diamond.new(max_letter)
  end

  def initialize(max_letter)
  end

  def representation
  "A\n"
  end
end

В этом есть несколько вариантов дизайна. Я выбрал заводской метод, потому что он показался более читаемым в тесте. Он делегирован конструктору, поэтому у меня будет экземпляр для выражения ожиданий. И, конечно же, как ожидают Себ, Алистер и Рон, я подделываю возвращение с константой. Очень просто!

Шаг 3: Выведите все необходимые строки

Следующим шагом, очевидно, является реализация случая «B». Хммм … Себ предложил также подделать его с константой, но это сразу же ведет меня по пути » if (letter == 'A') … elseif (letter == 'B') …» реализации. Я не против подделать возвращение, но я не хочу, чтобы моя программа выглядела так, как сказала Ахиллес Черепаха . Я считаю, что мне нужно вставить логику, прежде чем я достигну точки «решить всю проблему в« C »», предсказанной Себом и Алистером. Если я напишу тест для представления, мне нужно решить всю проблему для случая «B», и это слишком большой шаг для меня. В нем есть расчеты и форматирование одновременно, и это слишком много для меня, чтобы держать в голове сразу.

Я решил начать с вывода правильного количества строк.

  describe '.create(B)' do
  subject { Diamond.create('B') }

  it "has three lines in the representation" do
  expect(subject.representation.lines.count).to eql 3
  end
  end

Мне нужна строка для каждой буквы от «А» до максимальной буквы, а затем обратно до «А» без повторяющейся строки для максимальной буквы. Я закончил с этим:

  def initialize(max_letter)
  @letters= ('A' .. max_letter).to_a
  end

  def representation
  output= ""
  @letters.each { |letter| 
  output << letter+"\n"
  }
  @letters.reverse[1..-1].each { |letter| 
  output << letter+"\n"
  }
  output
  end

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

Сначала я хотел пройтись по массиву букв и вернуться обратно. На языке Си это было бы просто, но в Ruby это было неудобно. Обращая массив, за исключением max_letter, позвольте мне удобно использовать идиому «каждый». Я избегал численных расчетов, и я думаю, что это будет работать вплоть до случая «Z». Я проверяю это на случай «С».

  describe '.create(C)' do
  subject { Diamond.create('C') }

  it "has five lines in the representation" do
  expect(subject.representation.lines.count).to eql 5
  end
  end

Все отлично работает

Подведение итогов, где мы находимся

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

Когда я смотрю на этот код, я все больше осознаю, что выводю правильную букву в каждой строке, хотя у меня нет теста для этого. Неважно, этот тест придет. Я больше недоволен дублированием строк «описать» и «предмет» в тестах. Конечно, есть способ избежать этого дублирования, но я не нашел его в 10-минутном поиске в Интернете. Я решил оставить это сейчас. Это всего лишь ката, и, публикуя что-то «не так», люди обязательно скажут мне, как это сделать правильно. Я впервые использую явную «предметную» идиому, и она мне до сих пор незнакома.

Шаг 4: обработать отступ ранних строк

Форматирование всей строки все еще казалось мне большим шагом. Возможно, это было бы не так страшно, если бы я заранее задумался над этой проблемой, как это сделал Алистер. Вместо этого я просто ползал в своем собственном темпе. Если бы я выяснил, сколько пробелов было в начале каждой строки, это должно помочь мне выяснить, сколько пробелов идет в середине и в конце. Игнорируя эти более поздние проблемы, я добавил новое ожидание для случая «В».

  describe '.create(B)' do
  subject { Diamond.create('B') }

  it "has three lines in the representation" do
  expect(subject.representation.lines.count).to eql 3
  end

  it "indents the first line" do
  expect(subject.representation.lines[0]).to start_with "_A"
  end
  end

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

  def representation
  output= ""
  @letters.each { |letter| 
  @letters.reverse.each { |position|
       character= (position == letter) ? letter : '_'
       output << character
  }
  output << "\n"
  }
  @letters.reverse[1..-1].each { |letter| 
  output << letter+"\n"
  }
  output
  end

Опять же, этот код выглядит так, как будто он будет работать вплоть до случая ‘Z’, поэтому я добавлю пару проверок для случая ‘C’.

  it "indents the first line" do
  expect(subject.representation.lines[0]).to start_with "__A"
  end

  it "indents the second line" do
  expect(subject.representation.lines[1]).to start_with "_B_"
  end

Код кажется немного грязным. У меня есть дважды вложенный цикл с вычислением в середине. Я решил, что это будет читать более четко, если я извлеку расчет.

  def either_letter_or_blank (position, letter)
  (position == letter) ? letter : '_'
  end

  def representation
  output= ""
  @letters.each { |letter| 
  @letters.reverse.each { |position|
       output << either_letter_or_blank(position, letter)
  }
  output << "\n"
  }
  @letters.reverse[1..-1].each { |letter| 
  output << letter+"\n"
  }
  output
  end

Обратите внимание, что форматируется только первая половина ромба. Я перейду ко второй половине, но, возможно, будет проще, если я сначала отформатирую всю строку. В противном случае у меня будет два места, чтобы исправить это форматирование. Я приступаю к …

Шаг 5: обработать заполнение ранних строк

Давайте добавим пробелы к концам строк. Для случая «B» это означает…

  it "fills out the first line" do
  expect(subject.representation.lines[0]).to end_with "A_\n"
  end

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

  def representation
  output= ""
  @letters.each { |letter| 
  @letters.reverse.each { |position|
       output << either_letter_or_blank(position, letter)
  }
  @letters[1..-1].each { |position| 
       output << either_letter_or_blank(position, letter)
  }
  output << "\n"
  }
  @letters.reverse[1..-1].each { |letter| 
  output << letter+"\n"
  }
  output
  end

Это довольно уродливо, не правда ли. Наличие двух внутренних циклов внутри первого из двух внешних циклов — это гайки, тем более что это нужно будет повторить во втором внешнем цикле. Давайте извлечем другой метод.

  def line_for_letter (letter)
  line= ""
  @letters.reverse.each { |position|
  line << either_letter_or_blank(position, letter)
  }
  @letters[1..-1].each { |position| 
  line << either_letter_or_blank(position, letter)
  }
  line << "\n"
  end

  def representation
  output= ""
  @letters.each { |letter| 
  output << line_for_letter(letter)
  }
  @letters.reverse[1..-1].each { |letter| 
  output << letter+"\n"
  }
  output
  end

Мы почти закончили. Нисходящие строки теперь тривиальны. Я делаю большой шаг и указываю весь вывод «В».

  it "outputs the correct diamond" do
  expected= "_A_\n"+
      "B_B\n"+
      "_A_\n"
  expect(subject.representation).to eql expected
  end

И сделать это с одной строкой изменения.

  def representation
  output= ""
  @letters.each { |letter| 
  output << line_for_letter(letter)
  }
  @letters.reverse[1..-1].each { |letter| 
  output << line_for_letter(letter)
  }
  output
  end

Я думаю, что я закончил. Давайте проверим случай «С».

  it "outputs the correct diamond" do
  expected= "__A__\n"+
      "_B_B_\n"+
      "C___C\n"+
      "_B_B_\n"+
      "__A__\n"
  expect(subject.representation).to eql expected
  end

Да, это работает как ожидалось. Если бы это было производственное приложение, я бы поработал, чтобы защитить себя от плохого ввода. Я также подключил бы его к командной строке, как первоначально описал Себ. Да, и удалите эту оригинальную «настройку» спецификации. Полный код ниже и в GitHub .

Полная спецификация

require 'rspec'
require_relative './diamond'

describe "setup" do
  it "can call rspec" do
  expect(2).to eql(2)
  end
end

describe Diamond do
  describe '.create(A)' do
  subject { Diamond.create('A') }

  it "has a trivial representation" do
  expect(subject.representation).to eql "A\n"
  end
  end
  
  describe '.create(B)' do
  subject { Diamond.create('B') }

  it "has three lines in the representation" do
  expect(subject.representation.lines.count).to eql 3
  end

  it "indents the first line" do
  expect(subject.representation.lines[0]).to start_with "_A"
  end

  it "fills out the first line" do
  expect(subject.representation.lines[0]).to end_with "A_\n"
  end

  it "outputs the correct diamond" do
  expected= "_A_\n"+
             "B_B\n"+
             "_A_\n"
  expect(subject.representation).to eql expected
  end
  end
  
  describe '.create(C)' do
  subject { Diamond.create('C') }

  it "has five lines in the representation" do
  expect(subject.representation.lines.count).to eql 5
  end

  it "indents the first line" do
  expect(subject.representation.lines[0]).to start_with "__A"
  end

  it "indents the second line" do
  expect(subject.representation.lines[1]).to start_with "_B_"
  end

  it "outputs the correct diamond" do
  expected= "__A__\n"+
             "_B_B_\n"+
             "C___C\n"+
             "_B_B_\n"+
             "__A__\n"
  expect(subject.representation).to eql expected
  end
  end
end

Полный код

class Diamond
  def self.create(max_letter)
  Diamond.new(max_letter)
  end

  def initialize(max_letter)
  @letters= ('A' .. max_letter).to_a
  end

  def either_letter_or_blank (position, letter)
  (position == letter) ? letter : '_'
  end

  def line_for_letter (letter)
  line= ""
  @letters.reverse.each { |position|
  line << either_letter_or_blank(position, letter)
  }
  @letters[1..-1].each { |position| 
  line << either_letter_or_blank(position, letter)
  }
  line << "\n"
  end

  def representation
  output= ""
  @letters.each { |letter| 
  output << line_for_letter(letter)
  }
  @letters.reverse[1..-1].each { |letter| 
  output << line_for_letter(letter)
  }
  output
  end
end

TL; DR

Есть некоторые интересные вещи, которые я замечаю, сравнивая мое решение с подходами Себа, Алистера и Рона.

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

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

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

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

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

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

Конечно, Рон прав, что полезно думать о вещах в процессе реализации.