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

Автор

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

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

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

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. Синтаксис у for похож на синтаксис условных конструкций.

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

Теперь мы можем объединить условные конструкции и for. Немножко монструозно, но это работает:

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

Чтобы выводить в консоль результат вычислений внутри for, нужно использовать print().

Здесь стоит отметить, что for используется в R относительно редко. В подавляющем числе ситуаций использование for можно избежать. Обычно мы работаем в R с векторами или датафреймами, которые представляют собой множество относительно независимых наблюдений. Если мы хотим провести какие-нибудь операции с этими наблюдениями, то они обычно могут быть выполнены параллельно. Скажем, вы хотите для каждого испытуемого пересчитать его массу из фунтов в килограммы. Этот пересчет осуществляется по одинаковой формуле для каждого испытуемого. Эта формула не изменится из-за того, что какой-то испытуемый слишком большой или слишком маленький - для следующего испытуемого формула будет прежняя. Если Вы встречаете подобную задачу (где функцию можно применить независимо для всех значений), то без цикла for вполне можно обойтись.

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

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

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

Полезное: зачем циклы? прост

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

Зачем вообще избегать конструкций for? Некоторые говорят, что они слишком медленные, и частично это верно, если мы сравниваем с векторизованными функциями, которые написаны на более низкоуровневых языках. Но в большинстве случаев низкая скорость for связана с неправильным использованием этой конструкции. Например, стоит избегать ситуации, когда на каждой итерации for какой-то объект (вектор, список, что угодно) изменяется в размере. Лучше будет создать заранее объект нужного размера, который затем будет наполняться значениями:

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

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

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 =3. Помните, что NA по умолчанию — это логический тип данных, поэтому в этих функциях нужно использовать NA соответствующего типа NA_character_, NA_integer_, NA_real_, NA_complex_ (см. Глава 3.7).

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

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

Достаточно симпатичное решение есть в пакете dplyr — функция case_when(), которая работает с использованием формулы:

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