7  Условные конструкции и циклы

Автор

И.С. Поздняков

7.1 Выражения if, else, else if

Условные конструкции — стандартная часть почти любого языка программирования. R, конечно, — не исключение. Однако и здесь у R есть свои особенности, которые вытекают из ориентированности R на работу с векторами. В самом простом виде условная конструкция в R выглядит так:

if (условие) выражение

Вот так это будет работать на практике:

number <- 1
if (number > 0) "Положительное число"
[1] "Положительное число"

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

number <- 1
if (number > 0) {
  "Положительное число"
}
[1] "Положительное число"

В рассмотренной нами конструкции происходит проверка на условие. Если условие верно1, то происходит то, что записано в последующем выражении. Если же условие неверно2, то ничего не происходит.

Ключевое слово else позволяет задавать действие на все остальные случаи:

if (условие) выражение else выражение

Работает это так:

number <- -3
if (number > 0) {
  "Положительное число"
} else {
  "Отрицательное число или ноль"
}
[1] "Отрицательное число или ноль"

Иногда нам нужна последовательная проверка на несколько условий. Для этого есть конструкция else if. Вот как выглядит ее применение:

number <- 0
if (number > 0) {
  "Положительное число"
} else if (number < 0){
  "Отрицательное число"
} else {
  "Ноль"
}
[1] "Ноль"

Как мы помним, R — язык, в котором векторизация играет большое значение. Но вот незадача — условные конструкции не векторизованы в R! Давайте попробуем применить эти конструкции для вектора значений и посмотрим, что получится.

numbers <- -2:2
if (numbers > 0) {
  "Положительное число"
} else if (numbers < 0){
  "Отрицательное число"
} else {
  "Ноль"
}
Error in `if (numbers > 0) ...`:
! the condition has length > 1

Ошибка! Однако если у вас более старая версия R (до 4.2.0, апрель 2022), то вместо ошибки будет учитываться только первое значение вектора условий: остальные будут игнорироваться, при этом будет выводиться предупреждение. Как же посчитать для всего вектора сразу?

Полезное: применение условных конструкций

Невекторизованная конструкция if/else/else if неудобна при работе с данными, ее практически не используют для обработки данных. В основном она применяется при написании функций, чтобы проверить конкретное значение параметра или адекватность данных на входе (см. Глава 8).

7.2 Циклы for

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

for (переменная in последовательность) выражение

Например, переберем наш вектор numbers (тот самый, -2:2) и напечатаем каждое значение:

for (i in numbers) {
  print(i)
}
[1] -2
[1] -1
[1] 0
[1] 1
[1] 2

Обратите внимание: если мы хотим вывести в консоль результат операций внутри цикла, нужно эксплицитно использовать функцию print()3.

Теперь вернемся к нашей задаче — применить условную конструкцию к каждому значению вектора numbers, объединив конструкции if/else if/else и цикл for:

for (i in numbers) {
  if (i > 0) {
    print("Положительное число")
  } else if (i < 0) {
    print("Отрицательное число")
  } else {
    print("Ноль")
  }
}
[1] "Отрицательное число"
[1] "Отрицательное число"
[1] "Ноль"
[1] "Положительное число"
[1] "Положительное число"

Выглядит несколько монструозно, но это работает. Однако монструозность этой конструкции — не единственная проблема. Есть еще вопрос скорости: о циклах for в R часто говорят, что они медленные. На самом деле это верно лишь отчасти — чаще всего низкая скорость связана с тем, как именно написан цикл.

Типичная ошибка — наращивать объект на каждой итерации. Для компьютера это «дорогая» операция: когда объект увеличивается, R обычно не может просто дописать значение в конец — он выделяет под увеличенный объект новый участок памяти и копирует туда все старые значения, а на следующей итерации повторяет всё заново4. Лучше заранее создать объект нужного размера и затем заполнять его, переписывая значения в уже выделенных ячейках.

Здесь есть еще одна тонкость. До сих пор мы перебирали сами элементы вектора (for (i in numbers)). Но чтобы записывать результат в нужную позицию, нам нужны не значения, а их номера — индексы. Получить их помогает уже знакомая нам функция seq_along() (Глава 3.1): она возвращает номера элементов вектора — для нашего numbers это числа от 1 до 5.

Вот так делать не надо — объект наращивается на каждой итерации:

numbers_descriptions <- character(0) # начинаем с пустого вектора
for (i in seq_along(numbers)) {
  if (numbers[i] > 0) {
    numbers_descriptions <- c(numbers_descriptions, "Положительное число")
  } else if (numbers[i] < 0) {
    numbers_descriptions <- c(numbers_descriptions, "Отрицательное число")
  } else {
    numbers_descriptions <- c(numbers_descriptions, "Ноль")
  }
}
numbers_descriptions
[1] "Отрицательное число" "Отрицательное число" "Ноль"               
[4] "Положительное число" "Положительное число"

А вот так писать циклы правильно — объект сразу создается нужного размера:

numbers_descriptions <- character(length(numbers)) # создаем строковый вектор такой же длины, как исходный
for (i in seq_along(numbers)) {
  if (numbers[i] > 0) {
    numbers_descriptions[i] <- "Положительное число"
  } else if (numbers[i] < 0) {
    numbers_descriptions[i] <- "Отрицательное число"
  } else {
    numbers_descriptions[i] <- "Ноль"
  }
}
numbers_descriptions
[1] "Отрицательное число" "Отрицательное число" "Ноль"               
[4] "Положительное число" "Положительное число"

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

И это приводит нас к главному: в R цикл for обычно вообще не нужен5! Чаще всего мы работаем с векторами и датафреймами — наборами относительно независимых наблюдений, и операции над ними могут выполняться независимо друг от друга. Скажем, чтобы перевести массу каждого испытуемого из фунтов в килограммы, цикл не нужен: формула для всех одна и та же, и достаточно применить ее ко всему вектору сразу (это и есть векторизация, см. Глава 3.3). Конечно, внутри это всё равно будет работать через циклы — но для многих базовых функций, написанных на более низкоуровневых языках вроде C и C++, эти циклы выполняются гораздо быстрее, чем циклы, написанные на R.

Конечно, кто-то должен писать циклы for. Но это не обязательно должны быть вы.

Дженни Брайан

Даже когда расчет для одной строчки зависит от предыдущих, часто есть готовая векторизованная функция и на этот случай — например, cumsum() для накопительной суммы:

cumsum(1:10)
 [1]  1  3  6 10 15 21 28 36 45 55

Если же подходящей векторизованной функции нет, на помощь приходит семейство функций apply() (см. Глава 8.6).

Для продвинутых: дело не только в скорости

Циклов в R избегают в первую очередь ради ясности. R — функциональный язык6: код принято писать как последовательность функций, применяемых к данным, и тогда на виду главное — что именно вы делаете с данными, а не техническая возня с индексами и перебором (Wickham и др., 2023). Поэтому возьмите за правило: возникла мысль написать цикл — остановитесь и подумайте, нельзя ли обойтись без него. Вы удивитесь, как часто это получается. Почему так — хорошо разобрано в заметке «Vectorization in R: Why?».

7.3 Векторизованные условные конструкции: функции ifelse() и dplyr::case_when()

Из-за того, что конструкция if/else/else if не векторизованная, она редко используется непосредственно в операциях с данными, обычно она используется при написании функций (Глава 8.1) и разработке пакетов.

Альтернативой сочетанию условных конструкций и циклов for является использование встроенной функции ifelse(). Функция ifelse() принимает три аргумента:

  1. test = — условие (т.е. просто логический вектор, состоящий из TRUE и FALSE),

  2. yes = — что выдавать в случае TRUE,

  3. no = — что выдавать в случае FALSE.

На выходе получается вектор такой же длины, как и изначальный логический вектор (условие). Это очень похоже на ЕСЛИ() в Microsoft Excel.

ifelse(numbers > 0, "Положительное число", "Отрицательное число или ноль")
[1] "Отрицательное число или ноль" "Отрицательное число или ноль"
[3] "Отрицательное число или ноль" "Положительное число"         
[5] "Положительное число"         
Полезное: иногда ifelse() излишен

Периодически я встречаю у студентов строчку вроде такой: ifelse(условие, TRUE, FALSE). Эта конструкция избыточна, т.к. получается, что логический вектор из TRUE и FALSE превращается в абсолютно такой же вектор из TRUE и FALSE на тех же самых местах. Выходит, что ничего не меняется!

Осторожно: NA в ifelse() превращается в NA

NA в условии в ifelse() возвращает NA. Обычно это именно то поведение, которое вы ожидаете: если в исходном векторе есть неопределенность, то и на выходе должна остаться неопределенность на соответствующих позициях.

Пакеты {dplyr} и {data.table} предоставляют более быстрые и более строгие альтернативы для базовой функции ifelse() с аналогичным синтаксисом:

dplyr::if_else(numbers > 0, "Положительное число", "Отрицательное число или ноль")
[1] "Отрицательное число или ноль" "Отрицательное число или ноль"
[3] "Отрицательное число или ноль" "Положительное число"         
[5] "Положительное число"         
data.table::fifelse(numbers > 0, "Положительное число", "Отрицательное число или ноль")
[1] "Отрицательное число или ноль" "Отрицательное число или ноль"
[3] "Отрицательное число или ноль" "Положительное число"         
[5] "Положительное число"         

Если вы пользуетесь одним из этих пакетов (о них пойдет речь далее — см. Глава 9), то я советую пользоваться соответствующей функцией вместо базовой функции ifelse().

Осторожно: возвращение NA может привести к ошибкам

Обе функции будут избегать скрытого приведения типов (см. Глава 3.2) и намеренно выдавать ошибку при использовании разных типов данных в параметрах yes = и no =7. Помните, что NA по умолчанию — это логический тип данных, поэтому в этих функциях нужно использовать NA соответствующего типа NA_character_, NA_integer_, NA_real_, NA_complex_ (см. Глава 3.8).

У ifelse() тоже есть недостаток: он не может включать в себя дополнительных условий по типу else if. В простых ситуациях можно вставлять ifelse() внутри ifelse():

ifelse(numbers > 0,
       "Положительное число",
       ifelse(numbers < 0, "Отрицательное число", "Ноль"))
[1] "Отрицательное число" "Отрицательное число" "Ноль"               
[4] "Положительное число" "Положительное число"

Но что если условий много? Бесконечные ifelse() внутри ifelse() — это громоздко и нечитаемо. Достаточно симпатичное решение есть в пакете {dplyr} — функция case_when(). В качестве аргументов она принимает формулы — специальный класс объектов R (см. Глава 4.6), которые легко узнать по знаку тильды ~:

dplyr::case_when(
  numbers > 0 ~ "Положительное число",
  numbers < 0 ~ "Отрицательное число",
  numbers == 0 ~ "Ноль")
[1] "Отрицательное число" "Отрицательное число" "Ноль"               
[4] "Положительное число" "Положительное число"

Функция case_when() работает по той же логике, что и if/else/else if конструкция: сначала идет проверка на первое условие (как первое if в конструкции if/else/else if ). Если проверка проходит (то есть в условии получается TRUE), то соответствующее значение возвращается, а остальные условия не проверяются. Если же первое условие не выполняется, то идет проверка на следующее условие (аналог else if). Если же и оно не выполняется, то идет проверка на следующее (следующее else if), пока проверка не пройдет до последнего условия. Можно поставить значение по умолчанию с помощью параметра .default =, которое будет возвращаться, если все проверки выдали FALSE.

heroes <- read.csv("https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv", na.strings = c("NA", "-", "-99", " "))

heroes$weight_group <- dplyr::case_when(
  heroes$Weight > 200 ~ "overweight", # "if"
  heroes$Weight > 120 ~ "somewhat overweight", # "else if"
  heroes$Weight < 50 ~ "underweight", # next "else if"
  .default = "typical weight" # final "else" 
) 
Осторожно: не забывайте про запятые

Будьте внимательны с запятыми! Несмотря на довольно экстравагантный синтаксис, case_when() — это по-прежнему функция, а различные условия, которые вы прописываете внутри этой функции — это аргументы этой функции.

Важный момент: если ifelse() возвращает NA на NA в условии, что обычно нас устраивает (у нас нет данных, что у нас на входе, следовательно, не знаем, что на выходе), то case_when() такого не делает. NA в условии считается как FALSE, поэтому нужно дополнительно обрабатывать условие для него. Чтобы на место NA поставить NA, нужно записать вот так:

heroes$weight_group <- dplyr::case_when(
  heroes$Weight > 200 ~ "overweight", # "if"
  heroes$Weight > 120 ~ "somewhat overweight", # "else if"
  heroes$Weight < 50 ~ "underweight", # next "else if"
  is.na(heroes$Weight) ~ NA, # one more "else if", maps NA to NA
  .default = "typical weight" # final "else" 
  ) # final "else" 

В {data.table} тоже есть свой (более быстрый) аналог case_when() — функция fcase(). Синтаксис отличается только тем, что вместо формул используются простые запятые. То есть первый аргумент — условие, второй — значение, которое возвращается при верности первого аргумента, третий аргумент — условие, четвертый — возвращаемое значение при верности третьего аргумента и т.д.

data.table::fcase(
  numbers > 0, "Положительное число",
  numbers < 0, "Отрицательное число",
  numbers == 0, "Ноль")
[1] "Отрицательное число" "Отрицательное число" "Ноль"               
[4] "Положительное число" "Положительное число"

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


  1. В принципе, необязательно внутри должна быть проверка условий, достаточно просто значения TRUE.↩︎

  2. Аналогично, достаточно просто значения FALSE.↩︎

  3. Когда вы просто вводите команду в консоли, R неявно вызывает для результата функцию print() — поэтому значение и появляется на экране. Внутри циклов и функций такого автоматического вызова нет, так что print() приходится писать явно.↩︎

  4. Эта классическая ошибка вынесена во «второй круг ада» в эссе Патрика Бёрнса «The R Inferno» (Circle 2, «Growing Objects»).↩︎

  5. Циклы while и repeat, оставшиеся за пределами этой главы, в анализе данных встречаются еще реже, и всё сказанное относится к ним в той же мере.↩︎

  6. Эта идея раскроется полнее, когда мы дойдем до tidyverse и пайпов (Глава 10, Глава 10.4).↩︎

  7. В более свежих версиях пакета {dplyr} разработчики отказались от этой “строгости”, поэтому NA все-таки будут приводиться к нужному типу. Однако data.table::fifelse() остался строгим.↩︎