Site icon AppTractor

Чистый или быстрый код?

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

В видео анализируются некоторые из распространенных советов по созданию чистого кода, например, избегать операторов switch или if в пользу полиморфизма, и приводятся некоторые примеры на C++, чтобы показать, что код, полученный на их основе, менее производителен, чем код, который не является чистым.

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

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

Гипотеза: Чистый полиморфный код работает хуже, чем код, использующий If-Else

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

Полиморфный код

Начнем с полиморфного кода. Пример, приведенный в видео, использует Shape и вычисляет их площадь.

Давайте создадим протокол Shape, который определит свойство площади, доступное только для чтения:

protocol Shape {
   var area: Double { get }
}

Затем мы определим четыре различные фигуры, которые будут реализовывать этот протокол: Square, Rect, Triangle и Circle

// Square
class Square: Shape {
   let side: Int

   init(side: Int) {
      self.side = side
   }

   var area: Double { return Double(side * side) }
}

// Rect
class Rect: Shape {
   let base: Int
   let height: Int

   init(base: Int, height: Int) {
      self.base = base
      self.height = height
   }

   var area: Double { return Double(base * height) }
}

// Triangle
class Triangle: Shape {
   let base: Int
   let height: Int

   internal init(base: Int, height: Int) {
      self.base = base
      self.height = height
   }

   var area: Double { return Double(base * height) / 2.0 }
}

// Circle
class Circle: Shape {
   let radius: Int

   internal init(radius: Int) {
      self.radius = radius
   }

   var area: Double { return Double(radius * radius) * Double.pi }
}

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

Чтобы упростить тестовый код, мы можем подготовить служебную функцию, которая генерирует массив из 1 000 фигур, по 250 для каждого вида:

func shapes() -> [Shape] {
   let squares: [Shape] = (1...250).map {
      Square(side: $0)
   }
   let rects: [Shape] = (1...250).map {
      Rect(base: $0, height: $0*$0)
   }
   let triangles: [Shape] = (1...250).map {
      Triangle(base: $0 * $0, height: $0)
   }
   let circles: [Shape] = (1...250).map {
      Circle(radius: $0)
   }

return squares + rects + triangles + circles
}

Код на основе переключателей

В Swift подойти к этой проблеме можно и без использования классов и наследования. Можно использовать перечисления, которое мы назовем EnumShape, чтобы отличить его от протокола Shape:

enum EnumShape {
   case square(side: Int)
   case rect(base: Int, height: Int)
   case triangle(base: Int, height: Int)
   case circle(radius: Int)
}

Чтобы вычислить площадь, мы можем создать вычисляемое свойство в EnumShape, которое переключается по self, чтобы решить, какую функцию вызвать.

extension EnumShape {
   var area: Double {
      switch self {
         case .square(let side): return Double(side * side)
         case .rect(let base, let height): return Double(base * height)
         case .triangle(let base, let height): return Double(base * height)/2.0
         case .circle(let radius): return Double(radius * radius) * Double.pi
      }
   }
}

Давайте создадим простую служебную функцию для генерации тех же форм, что и в полиморфном примере, но с использованием перечисления:

func enumShapes() -> [EnumShape] {
   let squares: [EnumShape] = (1...250).map {
      .square(side: $0)
   }
   let rects: [EnumShape] = (1...250).map {
      .rect(base: $0, height: $0*$0)
   }
   let triangles: [EnumShape] = (1...250).map {
      .triangle(base: $0*$0, height: $0)
   }
   let circles: [EnumShape] = (1...250).map {
      .circle(radius: $0)
   }

return squares + rects + triangles + circles
}

Запуск тестов

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

Когда вы создаете новый файл XCTest, он создается с четырьмя предопределенными методами: setUpWithError, tearDownWithError, testExample и testPerformanceExample. Последний мы обычно удаляем, так как нас чаще интересуют модульные тесты, а не тесты производительности.

Для этой статьи мы хотели бы оставить последний метод: testPerformanceExample. Шаблонная реализация выглядит следующим образом:

func testPerformanceExample() throws {
   // This is an example of a performance test case.
   self.measure {
      // Put the code you want to measure the time of here.
   }
}

Давайте переименуем его в testShape и обновим код следующим образом:

final class ExampleTests: XCTestCase { 
   func testShape() throws {
      let shapes = shapes()

      self.measure {
         for var _ in (0..<10_000) {
            var acc = 0.0
            for shape in shapes {
               acc += shape.area
            }
         }
      }
   }
}

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

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

Давайте скопируем и вставим текст, переименуем копию в testEnumShape и обновим несколько строк, чтобы использовать перечисления:

func testEnumShape() throws {
   let shapes = enumShapes()
   self.measure {
      for _ in (0..<10_000) {
         var acc = 0.0
         for shape in shapes {
            acc += shape.area
         }
      }
   }
}

Теперь запустите тесты, нажав ⌘+U. При первом запуске некоторых тестов производительности Xcode не имеет базового значения, поэтому он представит такой пользовательский интерфейс:

Если кликнуть по ромбу с точкой внутри, он развернет индикатор и покажет кнопку с надписью Show (время на изображении не от этого эксперимента).

Давайте нажмем на Show, и откроется детальное представление, показанное ниже. Нажмем на Set Baseline, чтобы принять это значение в качестве базового.

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

Как и ожидалось, полиморфный код работает хуже, чем основанный на перечислениях, примерно на 30%.

Почему?

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

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

Значит ли это, что мы должны перестать писать чистый код?

Короткий ответ: это зависит от ситуации.

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

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

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

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

Заключение

Чистый код обычно представляется в виде догмы. Например: «именно так вы должны писать свой код и никак иначе».

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

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

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

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

Источник

Exit mobile version