Введение
Большинство начинающих программистов сталкивается со многими дилеммами в процессе написания кода, например задумываются о том, какой код будет востребован в индустрии. У каждой компании свои бенчмарки, лучшие практики для написания кода и основополагающие принципы. Однако есть один критерий кода, который единодушно поддерживается всеми: читаемость. Читаемый код и проживет дольше, и особых проблем с обслуживанием и пониманием не доставит. Более того, он позволяет будущим поколениям разработчиков легко вносить в него изменения. Проблема читаемости кода актуальна и для начинающих разработчиков Scala. В данной статье мне бы хотелось обратить внимание на ряд часто встречающихся ошибок.
Для всех наших примеров воспользуемся классом Movie:
object Genre extends Enumeration {
type Genre = Value
val HORROR, ACTION, COMEDY, THRILLER, ROMANCE= Value
}
class Movie(
movieName: String,
movieActors: List[String],
movieRating: Double,
movieGenre: Genre
) { val name: String = movieName
val actors: List[String] = movieActors
val rating: Double = movieRating
val genre: Genre = movieGenre
}
Обходимся без очень сложных лямбда-функций
Лямбда очень полезная функция, так как ее можно использовать, не присваивая переменной. Но применение сложных лямбда-функций может вызвать проблемы с читаемостью кода и процессом отладки. Рассмотрим пример, в котором нужно отнести фильм к одной из трех категорий: хороший, плохой, средний.
var movies: List[Movie] =_
movies.map(movie => if (movie.rating< 4) "Bad" else if movie.rating< 7) "Average" else "Good")
Лучше всего обработку этого сложного логического процесса предоставить отдельной функции. Тем самым мы обеспечим читаемость кода для разработчика.
movies.map(classifyMovie)
def classifyMovie(movie: Movie): String = {
val rating = movie.rating
if (rating < 4) "Bad"
else if (rating < 7) "Average"
else "Good"
}
К тому же в таком виде он гораздо лаконичнее.
Лучше val, чем var
Использование var часто приводит к случайным изменениям в переменных. В мире функционального программирования при сравнении изменяемых и неизменяемых структур данных предпочтение отдается последним. Обратимся к примеру, в котором пытаются изменить актеров в фильме.
val movie = new Movie(
"Mystic River",
List("Sean Penn", "Kevin Bacon"),
8,
Genre.THRILLER
)
movie.actors = movie.actors :+ "Laura Linney"
Эта операция не разрешена компилятором Scala. Чтобы добавить два неизменяемых списка, необходимо создать один новый.
val updatedActors = movie.actors :+ "Laura Linney"
Это особенно полезно в многопоточной системе, в которой два потока пытаются получить доступ к одной и той же переменной. И в этом случае безопаснее использовать val.
Сопоставление с образцом вместо оператора if/else
Одной из лучших практик для написании кода в Scala является использование сопоставления с образцом вместо традиционных операторов switch или громоздкого if/else. Создадим рекламные объявления на основе различных жанров кино.
def classifyGenre(genre: Genre): String = {
if (genre == HORROR)
"You should be scared!"
else if (genre == ACTION)
"Let's have a fight!"
else if (genre == COMEDY)
"You are so funny!"
else if (genre == THRILLER)
"Why so much suspense"
else if (genre == ROMANCE)
"A love story"
else
"I don't have a clue"
}
А теперь попробуем использовать сопоставление с образцом для этой же цели.
def classifyGenre(genre: Genre): String = {
movie.genre match {
case HORROR => "You should be scared!"
case ACTION => "Let's have a fight!"
case COMEDY => "You are so funny!"
case THRILLER => "Why so much suspense"
case ROMANCE => "A love story"
case _ => "I haven't a clue"
}
}
Для подобных случаев такой фрагмент кода гораздо понятнее. Он может использоваться вместо сложной логики if/else. Сопоставление с образцом также применяется с классами case для извлечения значений или других типов.
Option, Some и None вместо null
В функции, которая может возвращать значения null, следует использовать тип Option. Предлагаю вам игру в шарады, в которой сначала нужно отгадать количество слов в названии фильма. При использовании простого оператора if/else потребуется каждый раз выполнять явную проверку на null.
def guessTheWords(movieName: String): Int = {
if (movieName != null) {
movieName.split(" ").size
} else
0
}
А вот другой способ написания той же функции. Давайте определим movieName как Option[String] вместо String в нашем исходном классе Movie.
def guessTheWords(movieName: Option[String]): Int = {
movieName match {
case Some(x) => x.split(" ").size
case None => 0
}
}
Option является своего рода контейнером, в котором элемент заданного типа может присутствовать или отсутствовать. В данном фрагменте кода, когда название фильма отсутствует, Option используется для элегантной обработки NullPointerException. И читаемость этого варианта кода гораздо выше.
Обработка перечислений (enum) при отсутствии значения
Существует множество способов обработки случая, когда пользователь вводит недопустимое значение enum. Один из них — вернуть null в функцию, выполняющую его поиск.
def valueOf(genre: String): Genre = {
val lookup = Genre.values.find(_.toString == genre)
lookup match {
case Some(g) => g
case _ => null
}
}
Лучший же способ состоит в том, чтобы выбросить исключение, которое поможет пользователю осмысленно исправить значение. Кроме того, следует рассмотреть вариант использования сравнительной проверки без учета регистра.
def valueOf(genre: String): Genre = {
val genres = Genre.values
genres
.find(_.toString.toLowerCase == genre.toLowerCase)
.getOrElse(
throw new NoSuchElementException(
s"Supported values are ${genres} but found ${genre}"
)
)
}
Подобной функциональности также можно добиться, применив функцию withName.Если случай использования требует раннего обнаружения ошибок в процессе компиляции, то можно рассмотреть вариант с Sealed Traits (запечатанными трейтами), нои у него есть свои ограничения.
Преобразование с помощью foreach или map
Предположим, что существует метод, который переводит имена актеров фильма с английского на испанский язык (или любой другой). Анонимный метод называется translate.Простой цикл foreach вызовет эту функцию для всех элементов в коллекции movieActors и сохранит содержимое в ListBuffer, так как List не изменяем и не может быть преобразован.
def transformFunction(actors: List[String]): List[String] = {
var translatedActors = new ListBuffer[String]()
actors.foreach(translatedActors += translate(_))
translatedActors.toList
}
Как мы видим, foreach пытается изменить внешний список, известный как побочный эффект, который трудно распараллелить. Foreach походит для тех случаев использования, которые включают в себя операции без преобразования коллекции. Давайте используем map для вышеуказанного преобразования.
def transformFunction(actors: List[String]): List[String] = {
actors.map(translate)
}
Второй способ гораздо лаконичнее и не требует изменения коллекции, существующей вне лямбда-выражения. Map возвращает другой список того же размера, преобразуя каждый его элемент. Таким образом, с точки зрения производительности map определенно лучше, чем foreach.
Класс case вместо кортежа
Допустим, мы хотим порекомендовать названия фильмов на основании зрительского рейтинга. В качестве примера возьмем List ((Таинственная река), 8.0), (Властелин колец), 8.9)). Кортеж Scala как раз и существует для таких операций, которые выполняют роль небольшого контейнера для доступа к индивидуальным элементам.
def movieRatings(movies: List[Movie]): Unit = {
movies
.map(movie => (movie.name, movie.rating))
.filter(ratingTuple => ratingTuple._2 > 5)
.foreach(movie => print(movie._1, x._2))
}
Кортежи обычно используются в ситуациях, когда нам нужно объединить меньшее число элементов, но при этом мы не хотим создавать для них отдельный класс. Но если их количество в кортеже увеличивается, то это осложняет понимание контекста. А теперь посмотрим, как можно упростить процесс при помощи класса case.
case class Rating(name: String, rating: Double)
def movieRatings(movies: List[Movie]): Unit = {
movies
.map(movie => Rating(movie.name, movie.rating))
.filter(_.rating > 5)
.foreach(movie => print(movie.name, movie.rating))
}
Добавление новых полей происходит легче, если мы используем класс case, а не кортеж.
Интерполяция или конкатенация строк
Возвращаясь к нашей игре в шарады, предположим, что кто-то отгадал первую половину названия фильма, такого как “Властелин колец”. Полное название может быть создано с помощью конкатенации строк, в результате чего возникнет новая строка, использующая оператор +. То, как компилятор обрабатывает ошибки в случае конкатенации строк, понять не так-то просто.
def guessMovie(firstName: String, lastName: String): String = {
firstName + " " + lastName
}
Объединяя что-либо со String, следует рассмотреть вариант использования интерполяции строк. Он более удобный, безопасный, последовательный и читаемый.
def guessMovie(firstName: String, lastName: String): String = {
s"$firstName $lastName"
}
Производительность конкатенации строк и интерполятора (s, f и raw) может варьироваться в зависимости от длины строки.
Заключение
И это всего лишь начало. Читаемость кода — предмет бесконечных обсуждений в мире программирования. Некоторые могут утверждать, что код следует снабжать хорошими комментариями, чтобы другие разработчики лучше его понимали. Я же уверена, что наши небольшие действия, такие как соблюдение надлежащих правил именования переменных, поддержание межстрочного интервала, уменьшение логической сложности и следование выше рассмотренным практикам, несомненно приведет к более читаемому коду.
Ссылки
https://github.com/lloydmeta/enumeratum/blob/master/enumeratum-core/src/main/scala/enumeratum/Enum.scala
https://stackoverflow.com/questions/33593525/scala-safe-way-of-converting-string-to-enumeration-value
https://stackoverflow.com/questions/28319064/java-8-best-way-to-transform-a-list-map-or-foreach
https://nrinaudo.github.io/scala-best-practices/tricky_behaviours/string_concatenation.html
Читайте также:
- Как писать чистый код?
- Вычислительные затраты на написание чистого кода
- Функциональное программирование со Scala: введение
Перевод статьи Niharika Gupta: Readable code is better code