Объектно-ориентированное программирование

Системы ООП в R

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

В R в разное время было реализовано несколько разных систем объектно-ориентированного программирования. Если учитывать все экспериментальные (R5, mutatr), дублирующиеся по идеологии (ReferenceClasses) или просто заброшенные системы (OOP, proto, R.oo), то можно насчитать порядка девяти систем минимум. Из этих девяти хоть сколько-нибудь активно используются три, ещё одна, proto, в своё время повлияла на конструкцию пакета ggplot2.

Системы ООП, которые чаще всего можно встретить в настоящее время в R:

  • S3 — самая широко используемая система в R, в том числе и в base R. Впервые была имплементирована в третьей версии языка S в 1988 году (потому и S3). Весьма минималистична и, прямо говоря, мало похожа на то, что обычно понимают под ООП.

  • S4 — так же, как и S3, пришла из языка S, только уже четвёртой версии, в 1998 году. Во многом очень похожа на S3, однако уже более требовательна к разработчику: включает в себя формальные определения классов, проверку типов, наследование и инкапсуляцию, множественную диспетчеризацию (мультиметоды). Ещё одна из особенностей этой системы ООП — одни и те же S4-классы могут использоваться в разных пакетах, что активно применяется в экосистеме Bioconductor, в то время как S3-классы обычно характерны либо для base R, либо для одного конкретного пакета. Как и S3, поддерживается в base R.

  • R6 — повторяет и развивает систему ReferenceClasses, которую на момент появления иногда называли R5 (хотя сейчас это разные системы). Для использования R6 необходимо установить одноимённый пакет, так как в base R эта система не используется (в отличие от ReferenceClasses). В целом R6 больше всего похожа на классическое ООП, как, например, в Java: есть методы объектов, публичные и приватные методы, изменяемость объектов.

Базовые типы

Система объектов в R иерархична: в основе лежат так называемые базовые типы данных (fundamental data types), из которых создаются S3- и S4-классы. R6, в свою очередь, является расширением и творческим переосмыслением S3-классов.

Так как интерпретатор и среда R написаны на языке С, то все базовые типы данных R на уровне C могут быть соотнесены с определённым подтипом SEXPTYPE общего типа SEXP (symbolic expression). Впрочем, если говорить точнее, то все значения R-объектов являются C-структурами (нодами), где заголовок содержит описание типа и флаги для сборщика мусора и дебага, указатели на атрибуты ноды и на соседние ноды, а также собственно значения (указатели на блоки в памяти).

Полный список типов SEXPTYPE можно посмотреть в коде R в файле Rinternals.h. Всего возможно до 32 типов (так как кодируются пятью битами в заголовке), при этом их порядок фиксирован, и типы под номерами 12 и 13 были исключены ещё на ранних этапах становления R. Пользователи в работе сталкиваются только с некоторыми из базовых типов, прочие либо скрыты, либо вообще недоступны напрямую.

Как можно увидеть из таблицы, все базовые типы условно можно разделить на несколько групп: векторы и списки, функции, окружения, тип для S4-классов, лексические объекты, некоторые технические типы для работы со сборщиком мусора. Ещё несколько типов, несмотря на то, что присутствуют в списке, весьма редки в практике (WEAKREFSXP, EXTPTRSXP и проч.).

SEXPTYPE enum комментарий интерпретация
NILSXP 0 nil = NULL NULL, объект без данных
SYMSXP 1 symbols символьная запись, которая связана с каким-то значением, в общем виде — название объекта
LISTSXP 2 lists of dotted pairs список типа pairlist
CLOSXP 3 closures функции
ENVSXP 4 environments окружения
PROMSXP 5 promises: [un]evaluated closure arguments promises, обещания - окружение аргументов функции
LANGSXP 6 language constructs (special lists) calls, вызовы функции, а также захваченные выражения
SPECIALSXP 7 special forms внутренние функции, .Internal
BUILTINSXP 8 builtin non-special forms функции-примитивы, .Primitive
CHARSXP 9 “scalar” string type (internal only) указатели на уникальные строковые записи, которые могут использоваться в разных строковых векторах (STRSXP)
LGLSXP 10 logical vectors логические значения
INTSXP 13 integer vectors целые числа
REALSXP 14 real variables действительные числа
CPLXSXP 15 complex variables комплексные числа
STRSXP 16 string vectors строковые значения, набор указателей на CHARSXP
DOTSXP 17 dot-dot-dot object объект ...
ANYSXP 18 make “any” args work используется на уровне С, когда нужна проверка, что тип объекта корректен
VECSXP 19 generic vectors списки, list()
EXPRSXP 20 expressions vectors expressions, выражения
BCODESXP 21 byte code используется на уровне С для объектов в байт-коде, полученных в результате работы компилятора
EXTPTRSXP 22 external pointer используется на уровне С, указатель на внешний объект, наличие которого защищает от сборщика мусора
WEAKREFSXP 23 weak reference используется на уровне С, особый список (VECSXP) из четырёх элементов — ключ, значение, финализатор и указатель на следующий объект
RAWSXP 24 raw bytes значения в побайтовой записи
S4SXP 25 S4, non-vector тип для S4-классов, которые не унаследованы от векторов и других базовых типов
NEWSXP 30 fresh node created in new page используется на уровне С для выявления ошибок с памятью и сборщиком мусора,
FREESXP 31 node released by GC используется на уровне С для выявления ошибок с памятью и сборщиком мусора
FUNSXP 99 Closure or Builtin or Special используется на уровне С для выявления ошибок с памятью и сборщиком мусора

Базовые типы не могут быть названы системой ООП с её характеристиками в виде полиморфизма, наследования и прочими. Причиной этого является организация работы с разными типами в C-функциях, где для обработки разных типов реализованы логические ветвления (switch). Создание новых типов или изменение существующего набора тоже вызывает сложности, потому что необходимо актуализировать всю систему обработки разных типов. Правами и ресурсами на это обладают представители R Core Team, однако это весьма редкое событие из-за высокой трудоёмкости и спорной необходимости изменений и в какой-то мере по причине определённого консерватизма команды.

При работе в R объекты базовых типов можно определить по нескольким признакам. Во-первых, это объекты, у которых нет атрибута класса, но есть тип. Функция class() в данном случае будет неточна, так как сама по себе является функцией-дженериком и предназначена для работы с S3/S4-классами. Во-вторых, для базовых типов функция is.object() будет возвращать FALSE:

x <- c('a', 'b', 'c')
class(x)
## [1] "character"
## NULL
## [1] "character"
## [1] FALSE

При желании можно воспользоваться функциями пакета pryr, которые несколько более информативны. Например, sexp_type() возвращает соответствующий SEXPTYPE объекта, а otype() — какой системе ООП принадлежит объект (базовый тип или S3/S4/refClass).

x <- rnorm(5)
pryr::sexp_type(x)
## Registered S3 method overwritten by 'pryr':
##   method      from
##   print.bytes Rcpp
## [1] "REALSXP"
pryr::otype(x)
## [1] "base"

0.1 S3

Обобщённые функции

S3 сложно назвать системой объектно-ориентированного программирования в привычном многим разработчикам смысле. По сути, основная задача S3, как и S4, — обеспечить параметрический полиморфизм функций. Достигается это путём создания обобщённых (generic) функций, которые при использовании с разными объектами возвращают разный результат. Классический пример здесь функции summary(), print() и plot().

Например, при использовании функции summary() с числовым вектором мы получим параметры распределения — размах и квартили.

# вектор целых чисел
x <- sample(10, 15, replace = TRUE)
summary(x)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   1.000   5.500   9.000   7.133   9.000  10.000

А для фактора, для которого размах и квартили неприменимы, функция summary() вернёт частоту встречаемости значений по уровням:

x_fac <- factor(x)
summary(x_fac)
##  1  2  3  5  6  7  8  9 10 
##  1  1  1  1  1  1  1  5  3

Для таблицы результат будет третьим — статистики по колонкам в зависимости от того, какого класса колонки в таблице.

x_df <- data.frame(x, x_fac)
summary(x_df)
##        x              x_fac  
##  Min.   : 1.000   9      :5  
##  1st Qu.: 5.500   10     :3  
##  Median : 9.000   1      :1  
##  Mean   : 7.133   2      :1  
##  3rd Qu.: 9.000   3      :1  
##  Max.   :10.000   5      :1  
##                   (Other):3

Такое поведение возможно, так как функция summary() — обобщающая функция всего множества функций summary(), определённых для конкретных классов объектов. Другими словами, это обобщенный метод для большого набора классов, для каждого из которых метод может быть имплементирован по-своему. Диспетчеризация методов происходит по атрибуту класса объекта, метод класса обозначается как function_name.class_name(). Особенностью обобщённых функций в S3 является наличие метода по умолчанию (дефолтного) для случаев, когда метод этого класса не найден. Такие функции имеют вид function_name.default().

Список реализаций функции summary() для разных классов можно посмотреть с помощью methods():

methods(summary)
##  [1] summary.aov                    summary.aovlist*              
##  [3] summary.aspell*                summary.check_packages_in_dir*
##  [5] summary.connection             summary.data.frame            
##  [7] summary.Date                   summary.default               
##  [9] summary.ecdf*                  summary.factor                
## [11] summary.glm                    summary.infl*                 
## [13] summary.lm                     summary.loess*                
## [15] summary.manova                 summary.matrix                
## [17] summary.mlm*                   summary.nls*                  
## [19] summary.packageStatus*         summary.POSIXct               
## [21] summary.POSIXlt                summary.ppr*                  
## [23] summary.prcomp*                summary.princomp*             
## [25] summary.proc_time              summary.rlang_error*          
## [27] summary.rlang_trace*           summary.srcfile               
## [29] summary.srcref                 summary.stepfun               
## [31] summary.stl*                   summary.table                 
## [33] summary.tukeysmooth*           summary.vctrs_sclr*           
## [35] summary.vctrs_vctr*            summary.warnings              
## see '?methods' for accessing help and source code

Классы объектов

Вся система объектно-ориентированного программирования S3 стоит на двух ключевых операциях — определнии класса и объявлени методов (функций) этого класса. Ввиду своей древности и соответствующих периоду разработки требований к пользователям, S3 достаточно слабо регулирует действия пользователя и никак не защищает его от возможной ошибки. В частности, это проявляется в работе с классами: нет формальных методов объявления класса, создать объект нового класса или изменить класс объекта можно простым изменением атрибута class. Следует учитывать, что при изменении атрибута класса также происходит создание нового объекта — меняется его адрес в памяти. При этом при изменении класса объекта формат его хранения и презентации в C-типах останется всё так же одним из базовых типов.

x <- 1:7

# смотрим класс и тип объекта x
class(x)
## [1] "integer"
## [1] "integer"
pryr::otype(x)
## [1] "base"
# меняем класс объекта x
class(x) <- 'my_class'

# опять смотрим класс и тип объекта
class(x)
## [1] "my_class"
## [1] "integer"
pryr::otype(x)
## [1] "S3"

Другой метод, который наиболее явно отражает процесс присвоения класса, — явно изменить атрибут объекта class. Стоит сказать, что, с точки зрения языка R и S3 системы ООП, формулировки “создание экземпляра класса”, “присвоение класса” и “изменение атрибута класса” идентичны по смыслу и взаимозаменяемы.

attr(x, 'class') <- 'my_class_attr'
class(x)
## [1] "my_class_attr"
## [1] "integer"
pryr::otype(x)
## [1] "S3"

Несмотря на простоту способа создания экземпляра нового класса через изменение атрибута, чаще используют функцию structure(). Эта функция возвращает объект с заданным набором атрибутов, в том числе и атрибутом класса. Как правило, structure() используется в различных функциях-конструкторах, возвращающих объекты нового класса. Например, в функции as.data.frame.integer() (создание таблицы data.frame из вектора целых чисел) есть такая строчка:

structure(value, row.names = row.names, class = "data.frame")

Создадим экземпляр класса my_integer и проверим его класс и тип:

x <- structure(x, class = 'my_integer')
class(x)
## [1] "my_integer"
## [1] "integer"

При присвоении класса меняется атрибут, но не структура объекта — это как раз одно из проявлений вольности S3. Как следствие, если для этого класса определены какие-то функции, то R попробует применить их к этому объекту. Однако если структура объекта иная, то выполнение функции вернёт непредсказуемый результат или вообще завершится ошибкой. Например, если попытаться представить список векторов в виде таблицы (даже если соблюсти требование, чтобы векторы были одной длины), то выбор первой строки таблицы возвращает странный результат:

# создадим список
my_list <- list(v1 = 1:3, v2 = letters[1:3])

# присвоим класс data.frame и попробуем выбрать первую строчку
class(my_list) <- 'data.frame'
my_list[1, ]
## [1] v1 v2
## <0 rows> (or 0-length row.names)

Всё это служит иллюстрацией того, что создавать настоящий объект нового класса необходимо с использованием соответствующих функций — конструкторов классов. Для data.frame это функция data.frame() (вообще это достаточно распространенная практика — создавать одноименную с классом функцию-конструктор класса).

Мультиклассы

При желании можно вместо одного значения класса передать вектор значений, в результате объект будет двух и более классов. Например, вектор с('my_integer', 'my_numeric'). В результате получим два класса объекта:

x <- sample(10, 15, replace = TRUE)
x <- structure(x, class = c('my_integer', 'my_numeric'))
class(x)
## [1] "my_integer" "my_numeric"
## [1] "integer"

Использование нескольких классов объектов — несколько нетривиальное решение для обычных пользователей R, и при недостаточной опытности вполне может приводить к неожиданным результатам. Однако возможность создавать объекты нескольких классов также даёт и большую гибкость. В частности, это проявляется в применении обобщённых функций: если для какого-то класса отсутствует определённый метод, возможно, он определён для следующего в списке классов объекта. При этом если у объекта вообще нет класса (что возможно сделать), то будет применён метод класса default.

Классический пример объектов двух классов — таблица класса data.table, которая одновременно является объектом класса data.frame:

library(data.table)
my_dt <- as.data.table(mtcars)
class(my_dt)
## [1] "data.table" "data.frame"

Класс data.frame идёт в списке вторым, что даёт возможность применять к объектам data.table функции, которые были написаны для data.frame. Например, выбор всех строк из my_dt (копия mtcars), в которых hp > 250, будет выглядеть по-разному для data.table и для data.frame, но даст идентичный результат. И наоборот, использовать методы, определённые для data.table, с объектами data.frame можно было бы только в случае, если бы последовательность классов объекта была c('data.frame', 'data.table'):

# решение методами, определёнными для таблиц data.table
my_dt[hp > 250]
##     mpg cyl disp  hp drat   wt qsec vs am gear carb
## 1: 15.8   8  351 264 4.22 3.17 14.5  0  1    5    4
## 2: 15.0   8  301 335 3.54 3.57 14.6  0  1    5    8
# решение методами, определёнными для таблиц data.frame
my_dt[my_dt$hp > 250, ]
##     mpg cyl disp  hp drat   wt qsec vs am gear carb
## 1: 15.8   8  351 264 4.22 3.17 14.5  0  1    5    4
## 2: 15.0   8  301 335 3.54 3.57 14.6  0  1    5    8

Несмотря на то, что S3 формально не предполагает какую-либо иерархию методов, вполне осмысленно предполагать такую иерархию при перечислении классов при создании мультикласс-объектов. Это позволит более-менее явно конструировать систему методов классов и их взаимодействие. Те же классы data.table и data.frame вполне удобно рассматривать с точки зрения иерархии, когда data.frame — родительский класс, и, как следствие, всё, что применимо к data.frame, применимо к data.table. Но не наоборот.

Создание обобщённых функций и методов

UseMethod()

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

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

# создаём обобщённую функцию
my_summary <- function(object, ...) {
  UseMethod('my_summary')
}

При вызове UseMethod() создаётся новое окружение, в которое копируется текущее окружение, в каком была вызвана функция. Это приводит к нескольким эффектам. Во-первых, нет необходимости передавать все аргументы из списка аргументов обобщённой функции, они будут переданы в функцию класса автоматически. Во-вторых, в функцию класса будут переданы все объекты, которые были созданы или преобразованы до вызова UseMethod() в теле функции-дженерика. И в-третьих, результаты выражений после вызова UseMethod() будут потеряны.

При создании нового окружения в него создаются и/или копируются следующие объекты:

  • значения, переданные в аргументы функции-дженерика (объект и возможные доп. аргументы);
  • .Class — скрытый объект, содержит список классов объекта, создаётся при вызове функции-дженерика (отсутствует в глобальном окружении);
  • .Generic — скрытый объект, содержит название функции-дженерика, для пользователей может быть полезен при написании групповых дженериков;
  • .Method — скрытый объект, содержит название метода класса, который вызывается при вызове функции-дженерика;
  • .GenericCallEnv и .GenericDefEnv — окружения, в которых вызываются функция-дженерика и метод соответственно.

Методы пользовательских классов

После того, как функция-дженерик создана, можно создать функции (методы) классов. Для этого необходимо создать обычную функцию, в теле которой будут представлены все необходимые операции. Однако назвать эту функцию следует по маске generic_function_name.class_name(). Тогда при вызове функции-дженерика c объектом указанного класса будет выполнена именно эта функция. Точка как разделитель названий обобщённой функции и класса зашита в языке. В частности, это одна из причин, почему не следует использовать точки в названиях пользовательских объектов, особенно функций.

# создаём функцию, которая будет применяться к объектам класса my_numeric
my_summary.my_numeric <- function(object, round_digits = 1) {
  stats <- c(
      'Min' = min(object),
      quantile(object),
      'Max' = max(object),
      'NA' = sum(is.na(object)),
      'N_obs' = length(object),
      'sd' = sd(object)
  )
  stats <- round(stats, digits = round_digits)
  stats
}

Проверим, как работает созданная пара функции-дженерика и функции для обработки класса my_numeric:

# создаём вектор целых чисел
x <- sample(10, 15, replace = TRUE)
summary(x)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##     1.0     2.0     3.0     5.2     8.5    10.0
# меняем класс объекта на 'my_numeric'
x <- structure(x, class = 'my_numeric')

# вызываем нашу функцию-дженерик
my_summary(x, round_digits = 1)
##   Min    0%   25%   50%   75%  100%   Max    NA N_obs    sd 
##   1.0   1.0   2.0   3.0   8.5  10.0  10.0   0.0  15.0   3.5

Метод для default-класса

Также для пользовательских дженерик-функций можно создавать методы по умолчанию. Эти методы используются для обработки ситуаций, когда для объекта нового класса метод не определён. Логика совершенно идентичная, единственное, в качестве названия класса указывается default. Фактически создаётся виртуальный класс, к которому обращается обобщающая функция в ситуациях, когда не находит метода класса переданного объекта. Сделаем так, чтобы эта функция возвращала параметр, который есть у всех объектов, — длину:

# метод по умолчанию
my_summary.default <- function(object) {
  stats <- length(object)
  stats
}

Попробуем применить дженерик-функцию my_summary() к новому классу, для которого ещё не созданы частные методы:

# создаём объект класса my_list
x <- list(e1 = rnorm(5), e2 = letters[1:5], e3 = mean)
x <- structure(x, class = c('my_list'))
str(x)
## List of 3
##  $ e1: num [1:5] -0.249 -1.531 -0.949 -0.294 1.429
##  $ e2: chr [1:5] "a" "b" "c" "d" ...
##  $ e3:function (x, ...)  
##  - attr(*, "class")= chr "my_list"
# смотрим, какие методы обобщены функцией my_summary
methods(my_summary)
## [1] my_summary.default    my_summary.my_numeric
## see '?methods' for accessing help and source code
# применяем my_summary к объекту класса my_list
my_summary(x)
## [1] 3

Создавать метод для default-класса — достаточно хорошая практика, поэтому при написании дженериков необходимо начинать именно с этого метода, хотя бы с создания заглушки вида stop("Not implemented").

NextMethod()

Несколько сложнее ситуации, когда у объекта несколько классов. В таких случаях дженерик-функция перебирает cписок классов объекта до тех пор, пока не найдёт класс, для которого объявлена соответствующая функция, обычно это всё же первый класс. Например, у нас есть объект, класс которого my_integer и второй класс — my_numeric. Для класса my_integer нет определённых методов. Согласно правилам диспетчеризации методов, для этого объекта должен быть использован метод второго класса, my_numeric. Если бы этого метода не было или у объекта был бы только один класс my_integer, то использовался бы метод my_summary.default(), который мы определили выше.

# создадим класс my_integer
x <- sample(10, 15, replace = TRUE)

# применяем функцию, когда метод не определён
x <- structure(x, class = 'my_integer')
my_summary(x)
## [1] 15
# добавляем ещё один класс объекту
x <- structure(x, class = c('my_integer', 'my_numeric'))
my_summary(x)
##   Min    0%   25%   50%   75%  100%   Max    NA N_obs    sd 
##   2.0   2.0   5.0   6.0   8.0  10.0  10.0   0.0  15.0   2.3

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

# создаём метод с помощью NextMethod()
my_summary.my_integer <- function(object, round_digits = 1) {
  NextMethod()
}

# применяем дженерик-функцию
my_summary(x)
##   Min    0%   25%   50%   75%  100%   Max    NA N_obs    sd 
##   2.0   2.0   5.0   6.0   8.0  10.0  10.0   0.0  15.0   2.3

Функция NextMethod() схожа с UseMethod() по конструкции с некоторыми исключениями. Во-первых, обращается к скрытому объекту .Class, который создаётся при вызове функции-дженерика и хранится в её окружении. Это означает, что можно менять класс объекта в теле обобщающей функции, хотя такой подход не рекомендуется. Во-вторых, NextMethod() создаёт окружение, в котором выполняется соответствующий метод, и возвращает результат выполнения этого метода.

Внутренние обобщённые функции и группы

Помимо пользовательских обобщённых функций, в R присутствует определённый набор внутренних (Internal) обобщённых функций, которые могут быть применимы к разным классам объектов. Для всех этих функций-дженериков могут быть написаны самостоятельные пользовательские методы:

  • [, [[, $, [<-, [[<-, $<-;
  • length, length<-, dimnames, dimnames<-, dim, dim<-, names, names<-, levels<-;
  • c, unlist, cbind, rbind;
  • as.character, as.complex, as.double, as.integer, as.logical, as.raw, as.vector, is.array, is.matrix, is.na, is.nan, is.numeric, rep, seq.int (дженерик для seq(), int от internal), xtfrm.

Простейший пример определения метода для внутренней дженерик-функции:

# создаём объект класса my_class
x <- structure(1:7, class = 'my_class')

# создаём метод
length.my_class <- function(object) {
  object * object
}

# применяем internal-дженерик length к объектам разных классов
length(letters[1:5])
## [1] 5
## [1]  1  4  9 16 25 36 49
## attr(,"class")
## [1] "my_class"

К внутренним обобщённым функциям относятся также так называемые групповые дженерики (groupGenerics). Отличительная особенность этих функций: надо определять всю группу методов для нового класса, в противном случае будут работать только конкретные определённые методы. Всего существует четыре группы таких функций, собственные группы добавить нельзя:

  • Math (математические функции) — abs, sign, sqrt, floor, ceiling, trunc, round, signif, exp, log, expm1, log1p, cos, sin, tan, cospi, sinpi, tanpi, acos, asin, atan, cosh, sinh, tanh, acosh, asinh, atanh, lgamma, gamma, digamma, trigamma, cumsum, cumprod, cummax, cummin.
  • Ops (операторы) — +, -, *, /, ^, %%, %/%, &, |, !, ==, !=, <, <=, >=, >.
  • Summary (описательные статистики) — all, any, sum, prod, min, max, range.
  • Complex (для комплексных чисел) — Arg, Conj, Im, Mod, Re.

Попробуем определить группу Summary для класса my_class, но только с двумя функциями из всего списка функций группы. Посмотрим, как с объектами класса my_class будут работать другие функции, которые не были определены для класса:

# создаём объект класса my_class
x <- structure(1:7, class = 'my_class')

# меняем дженерик-группу Summary
Summary.my_class <- function(object, ...) {
  switch(.Generic,
         min = 'min object',
         max = 'max object'
  )
}
min(x)
## [1] "min object"
max(x)
## [1] "max object"
# не определены для класса
sum(x)
## NULL
prod(x)
## NULL

Одна из особенностей внутренних функций-дженериков: если нет определённого метода, то они ориентируются не на атрибут класса объекта, а на тип объекта. В результате можно не реализовывать метод по умолчанию, им будет базовый метод, который применим для этого типа данных. Например, у нас есть вектор числовых значений с классом my_class, для которого не определена функция сложения (+). Однако операция сложения все равно выполняется, и результатом будет объект того же класса, что и исходный объект.

# класс объекта
class(x)
## [1] "my_class"
# результат сложения
x + 2
## [1] 3 4 5 6 7 8 9
## attr(,"class")
## [1] "my_class"

Диспетчеризация методов

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

Самый простой часто встречающийся случай: есть обобщающая функция generic_function_name и есть какое-то количество методов для разных классов, которые обобщены этой функцией. В такой ситуации происходит поиск соответствующего метода по названию класса. Классический пример с summary(x), когда x может быть числовым вектором, фактором, таблицей, lm-моделью и так далее.

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

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

  • оба объекта одного класса — применяется определённый для класса метод;

  • оба объекта разных классов — используется внутренний метод для каждого класса, а пользователю выводится предупреждение;

  • один из методов является внутренним методом — используется метод второго аргумента.

S4

Определение класса

Система классов S4 была призвана исправить недочёты системы S3, поэтому одним из ключевых направлений работы стал процесс объявления классов и создание экземпляров классов.

В отличие от S3, где создание объекта нового класса — это простое присвоение нового значения в атрибут класса, в S4 классы определяются явно, с помощью функции setClass().

Функция setClass() имеет несколько аргументов, самые важные из них:

  • Class — название нового класса, обычно в UpperCamelCase-написании.

  • slots — слоты (поля) класса. По сути, это набор переменных, в которых хранятся данные объекта этого класса. В setClass() слоты задаются как именованный вектор парами “название слота” и “тип данных”. Названия слотов ‘class’ / ‘Class’ запрещены. В том случае, если есть необходимость оставить класс слота неопределённым, можно указать ANY. В редких случаях также можно просто указать названия слотов, и тогда для них будет также проставлен класс ANY. Если новый класс наследован от базового типа или S3-класса, то будет создан также скрытый слот .Data.

  • contains — вектор родительских классов (суперклассов), от которых унаследован создаваемый класс.

  • prototype — список значений по умолчанию для слотов, своего рода конструктор экземпляра класса по умолчанию. В принципе, это необязательный аргумент, и вполне можно либо обойтись без него. Тем не менее, если предполагается, что классом будут пользоваться внешние пользователи (например, в пакете), то рекомендуется задавать значения слотов по умолчанию.

Пример определения класса (объект City можно не создавать и просто вызвать setClass()):

City <- setClass(
  Class = 'city', 
  slots = c(
    name = 'character', 
    abb = 'character', 
    population = 'ANY'
  )
)

Посмотреть определение класса можно с помощью функций getClass() / getClassDef():

getClass('city')
## Class "city" [in ".GlobalEnv"]
## 
## Slots:
##                                        
## Name:        name        abb population
## Class:  character  character        ANY

Удалить созданный класс можно с помощью removeClass().

Переопределение класса

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

# переопределяем класс
setClass(
  Class = 'city', 
  slots = c(extra_slot = 'ANY')
)

# вызываем ранее созданный объект класса city
msk
## Error in eval(expr, envir, enclos): object 'msk' not found
# переопределим класс city обратно
setClass(
  Class = 'city', 
  slots = c(name = 'character', abb = 'character', population = 'ANY')
)

Инстанцирование классов

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

msk <- new(Class = 'city', name = 'Moscow', abb = 'Msk')
str(msk)
## Formal class 'city' [package ".GlobalEnv"] with 3 slots
##   ..@ name      : chr "Moscow"
##   ..@ abb       : chr "Msk"
##   ..@ population: NULL

Второй способ использует собственно объект — определение класса, если он был создан при вызове setClass():

msk <- City(name = 'Moscow', abb = 'Msk')
str(msk)
## Formal class 'city' [package ".GlobalEnv"] with 3 slots
##   ..@ name      : chr "Moscow"
##   ..@ abb       : chr "Msk"
##   ..@ population: NULL

Определить, к какой системе ООП относится созданный объект, можно всё так же с помощью с помощью функции pryr::otype():

pryr::otype(msk)
## [1] "S4"

Определить S4-класс объекта можно так же, как и S3-классы, с помощью функции class(). Помимо класса объекта в выводе будет также пакет или окружение, в котором был определён класс.

class(msk)
## [1] "city"
## attr(,"package")
## [1] ".GlobalEnv"

Проверить, является ли объект экземпляром какого-то определённого класса, можно с помощью функции is() или inherits():

is(msk, 'city')
## [1] TRUE
inherits(msk, 'city')
## [1] TRUE

Виртуальные классы

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

Более простой способ создать виртуальный класс — объявить класс без аргумента contains, то есть создаваемый класс ни от какого другого класса не унаследован. При попытке создать экземпляр виртуального класса интерпретатор должен вернуть ошибку:

# определяем класс без contains
setClass('virt')

# пробуем создать экземпляр класса
virt_ex <- new('virt')
## Error in new("virt"): trying to generate an object from a virtual class ("virt")

Проверить, является ли класс виртуальным, можно с помощью функции isVirtualClass(). Правда, у этой функции есть одна особенность: она предназначена только для работы с S4-классами. Прочие классы (S3, например) будут также признаны виртуальными:

# проверяем, является ли класс virt виртуальным
isVirtualClass('virt')
## [1] TRUE
# пробуем s3-класс
x <- 'a'
class(x) <- 'my_s3_class'
isVirtualClass('my_s3_class')
## [1] TRUE

Создание S4-классов из S3-классов

В R S3- и S4-системы существуют параллельно. В результате нередки ситуации, когда в работе одновременно используются объекты S3- и S4-классов. В принципе, в механизм диспетчеризации методов заложена процедура обработки S3-классов и базовых классов, однако настоятельно рекомендуется при работе с S4-классами и методами (например, при написании собственного пакета, использующего систему S4) регистрировать S3-классы как S4-классы.

Для подобной операции используется функция setOldClass() (аргумент S4Class опционален и указывает соответствующий S4-класс, если он определён):

# смотрим, есть ли определение класса в S4-системе
getClass('my_s3_class')
## Error in getClass("my_s3_class"): "my_s3_class" is not a defined class
# регистрируем класс как S4-класс
setOldClass('my_s3_class')

# ещё раз смотрим определение
getClass('my_s3_class')
## Virtual Class "my_s3_class" [in ".GlobalEnv"]
## 
## Slots:
##                 
## Name:   .S3Class
## Class: character
## 
## Extends: "oldClass"
## [1] TRUE

При создании пакета methods некоторые часто используемые S3-классы были уже зарегистрированы как S4-классы (аналогичная процедура рекомендуется при написании собственных пакетов). Список этих классов можно посмотреть, вызвав скрытый объект .OldClassesList. Несколько значений обозначают наследование от специального класса к более общему:

.OldClassesList[1:3]
## [[1]]
## [1] "anova"      "data.frame"
## 
## [[2]]
## [1] "mlm" "lm" 
## 
## [[3]]
## [1] "aov" "lm"

Наследование классов

В S4 реализовано явное наследование и иерархия классов. Если класс унаследован от какого-то другого класса, то при определении класса с помощью setClass() в атрибут contains передаётся название родительского класса (вектор названий классов в случае множественного наследования). Соответственно, классы-потомки наследуют все слоты родительских классов и добавляют свои.

Создадим, например, класс District, который будет унаследован от City и будет описывать административные округа/районы городов. Добавим ещё два слота — название и аббревиатуру административного округа:

setClass(
    'district',
    contains = 'city',
    slots = c(district_name = 'character', district_abb = 'character')
  )
getClassDef('district')
## Class "district" [in ".GlobalEnv"]
## 
## Slots:
##                                                                             
## Name:  district_name  district_abb          name           abb    population
## Class:     character     character     character     character           ANY
## 
## Extends: "city"

Создадим экземпляр класса. Как мы видим, у класса в результате получилось пять слотов: два задаются при определении класса и описывают информацию о районе, а три унаследованы от родительского класса.

vasileostrovsky <-
  new(
    Class = 'district',
    name = 'Saint-Petersburg',
    abb = 'SPb',
    district_name = 'Vasileostrovsky district'
  )
str(vasileostrovsky)
## Formal class 'district' [package ".GlobalEnv"] with 5 slots
##   ..@ district_name: chr "Vasileostrovsky district"
##   ..@ district_abb : chr(0) 
##   ..@ name         : chr "Saint-Petersburg"
##   ..@ abb          : chr "SPb"
##   ..@ population   : NULL

Узнать суперкласс какого-либо класса в S4 можно с помощью функции selectSuperClasses(). Также можно проверить, является ли какой-либо класс расширением другого класса, с помощью функции extends():

# определяем суперкласс класса `district`
selectSuperClasses('district')
## [1] "city"
# проверяем, является ли класс `city` суперклассом для `district`
extends('district', 'city')
## [1] TRUE
# проверяем, является ли класс `district` суперклассом для `city`
extends('city', 'district')
## [1] FALSE

Преобразование классов

S4-система предполагает два варианта преобразования классов — преобразование связанных иерархическими отношениями классов (класс-суперкласс) и преобразование в независимые классы.

Преобразование экземпляра суперкласса в подкласс и наоборот производится с помощью функции as(), где в аргументах указывается объект и целевой класс. При этом лишние слоты утрачиваются, а недостающие не заполняются или заполняются значением по умолчанию (если указано в prototype при определении класса).

Преобразуем объект суперкласса city в класс district. Слоты district_name и district_abb не содержат никаких данных:

getClass(msk)
## An object of class "city"
## Slot "name":
## [1] "Moscow"
## 
## Slot "abb":
## [1] "Msk"
## 
## Slot "population":
## NULL
as(msk, 'district')
## An object of class "district"
## Slot "district_name":
## character(0)
## 
## Slot "district_abb":
## character(0)
## 
## Slot "name":
## [1] "Moscow"
## 
## Slot "abb":
## [1] "Msk"
## 
## Slot "population":
## NULL

Обратно из класса district в суперкласс city. Слотов district_name и district_abb просто нет:

as(vasileostrovsky, 'city')
## An object of class "city"
## Slot "name":
## [1] "Saint-Petersburg"
## 
## Slot "abb":
## [1] "SPb"
## 
## Slot "population":
## NULL

Второй способ преобразования классов — это явное преобразование, определённое с помощью функции setAs(). В аргументах from и to функции явно указывается, из какого класса в какой необходимо преобразовать объект, а аргументе def — собственно процесс преобразования. В аргумент def можно передать как отдельно объявленную функцию, так и анонимную. Преобразуем объект msk класса city в data.frame. Простым as.data.frame() это, естественно, сделать не получится, поэтому используем setAs().

setAs(from = 'city', to = 'data.frame', def = function(from) {
  res <- list(
    from@name,
    from@abb,
    from@population
  )
  names(res) <- slotNames(from)
  for (i in seq_along(res))
    res[[i]] <- ifelse(is.null(res[[i]]), NA, res[[i]])
  as.data.frame(res, stringsAsFactors = FALSE)
})

Функция setAs() только определяет процедуру преобразования типов, само же преобразование производится всё так же с помощью функции as():

msk_df <- as(msk, 'data.frame')
class(msk_df)
## [1] "data.frame"
msk_df
##     name abb population
## 1 Moscow Msk         NA

Обобщенные функции

Обобщенные функции создаются с помощью функции setGeneric(). Можно выделить несколько вариантов создания дженериков. Самый простой — когда у нас есть какой-то класс и мы также создали функцию, которая должна работать с этим классом. Как правило, подобное поведение используется, когда нужно перегрузить уже существующую функцию из другого пакета, чтобы она работала с созданным нами классом. В таком случае последовательность действий выглядит следующим образом: мы из уже объявленной функции с нужным нам поведением делаем дженерик (передав название функции в аргумент name, первый в списке аргументов). При этом исходная функция становится методом по умолчанию (аналог default в S3):

# создадим функцию, которую потом сделаем дженериком
dummy_fun <- function(x) print(class(x))
setGeneric('dummy_fun')
## [1] "dummy_fun"
# применяем к разным классам
dummy_fun(iris)
## [1] "data.frame"
dummy_fun(vasileostrovsky)
## [1] "district"
## attr(,"package")
## [1] ".GlobalEnv"

Далее можно перегрузить эту функцию для какого-нибудь конкретного класса с помощью setMethod():

setMethod('dummy_fun', 'district', function(x) print(c(x@name, x@abb)))
dummy_fun(iris)
## [1] "data.frame"
dummy_fun(vasileostrovsky)
## [1] "Saint-Petersburg" "SPb"

При этом, строго говоря, setGeneric() можно и не вызывать. Достаточно будет указать во время создания метода класса, что какая-то функция должна быть дженериком, и тогда дженерик будет сформирован автоматически. Метод при создании класса будет использоваться для этого класса, а исходная функция — как метод по умолчанию.

from_method <- function(x) class(x)
setMethod('from_method', 'district', definition = function(x) print(x@abb))
## Creating a generic function from function 'from_method' in the global environment
class(from_method)
## [1] "standardGeneric"
## attr(,"package")
## [1] "methods"
from_method(iris)
## [1] "data.frame"
from_method(vasileostrovsky)
## [1] "SPb"

Второй случай — когда у нас нет ранее объявленной или импортированной функции. Тогда можно с помощью setGeneric() объявить функцию с целевым поведением (которое будет методом по умолчанию). Созданная функция будет иметь класс standardGeneric:

# указываем, что superclass — это функция, возвращающая суперкласс объекта
setGeneric('superclass', function(x) selectSuperClasses(x))
## [1] "superclass"
class(superclass)
## [1] "standardGeneric"
## attr(,"package")
## [1] "methods"
superclass('district')
## [1] "city"

Третий вариант создания функций-дженериков в системе S4 не предполагает какого-то поведения по умолчанию. В таком подходе в аргументе name функции setGeneric() указывается название новой функции-дженерика, а в аргументе def — анонимная функция, вызывающая standardGeneric(). Функция standardGeneric() не используется сама по себе (как и UseMethod()) и нужна для схожих целей — для диспетчеризации методов.

В примере ниже мы объявляем функцию-дженерик dummy_fun() и указываем, что применять её надо при работе с определёнными классами, для которых созданы соответствующие методы (нет методов по умолчанию):

# объявляем дженерик и метод для класса 'district'
setGeneric('dummy_fun2', def = function(x) standardGeneric('dummy_fun2'))
## [1] "dummy_fun2"
setMethod('dummy_fun2', 'district', function(x) print(c(x@name, x@abb)))
dummy_fun2(vasileostrovsky)
## [1] "Saint-Petersburg" "SPb"
dummy_fun2(iris)
## Error in (function (classes, fdef, mtable) : unable to find an inherited method for function 'dummy_fun2' for signature '"data.frame"'

Помимо name и def, в списке аргументов setGeneric() есть ещё и другие аргументы, один из самых важных — signature. Именно с его помощью определяется, какие аргументы функции в name будут использоваться для диспетчеризации методов. Это особенно полезно в тех ситуациях, когда предполагается, что не все аргументы значимы для диспетчеризации или есть аргументы со значениями по умолчанию. Если в signature ничего не указано, то по умолчанию для диспетчеризации используются все аргументы функции. Есть один важный нюанс: если в setGeneric() используется уже существующая функция, сигнатуру (как и прочие дополнительные параметры) указывать не надо.

Группы дженериков

Дженерики также могут быть отнесены к группам (как, например, функции группы Ops в S3). Группы могут быть как пользовательскими, так и уже предзаданными в базовом пакете methods. Основная цель введения групп — упростить и сделать более надёжным механизм диспетчеризации методов, так как при определении метода указывается группа дженериков и пакет, которому принадлежит эта группа (в случае пользовательских групп).

Предзаданные группы схожи с теми, что присутствуют в S3, однако не тождественны. Все функции этих групп — базовые функции R, в том числе и примитивы (обёртки над С-кодом).

  • Ops включает в себя три подгруппы операторов:

    • ‘Arith’: ‘+’, ‘-’, ‘*’, ‘^’, ‘%%’, ‘%/%’, ‘/’;
    • ‘Compare’: ‘==’, ‘>’, ‘<’, ‘!=’, ‘<=’, ‘>=’;
    • ‘Logic’: ‘&’, ‘|’.
  • Math: ‘abs’, ‘sign’, ‘sqrt’, ‘ceiling’, ‘floor’, ‘trunc’, ‘cummax’, ‘cummin’, ‘cumprod’, ‘cumsum’, ‘log’, ‘log10’, ‘log2’, ‘log1p’, ‘acos’, ‘acosh’, ‘asin’, ‘asinh’, ‘atan’, ‘atanh’, ‘exp’, ‘expm1’, ‘cos’, ‘cosh’, ‘cospi’, ‘sin’, ‘sinh’, ‘sinpi’, ‘tan’, ‘tanh’, ‘tanpi’, ‘gamma’, ‘lgamma’, ‘digamma’, ‘trigamma’.

  • Math2: ‘round’, ‘signif’.

  • Summary: ‘max’, ‘min’, ‘range’, ‘prod’, ‘sum’, ‘any’, ‘all’.

  • Complex: ‘Arg’, ‘Conj’, ‘Im’, ‘Mod’, ‘Re’.

Пользовательскую группу дженериков можно задать с помощью функции setGroupGeneric(), которая по поведению аналогична функции setGeneric() за несколькими исключениями. Во-первых, рекомендуется указывать все функции группы в аргументе knownMembers. Во-вторых, как и все групповые дженерики, группа, определённая с помощью setGroupGeneric(), не может быть использована как самостоятельная функция, так как в определении функции (def) в первую очередь важен список аргументов функций-дженериков группы, а сама функция должна возвращать NULL. Соответственно, все функции группы должны иметь одинаковый список аргументов. Впрочем, если хочется иметь отдельный метод, одноимённый названию группы, можно объявить его с помощью setMethod().

# объявляем групповой дженерик
setGroupGeneric(
  name = 'group_generic',
  def = function(x, y) NULL,
  knownMembers = c('my_mult', 'my_sum')
)
## [1] "group_generic"
# смотрим описание группы
group_generic
## groupGenericFunction for "group_generic" defined from package ".GlobalEnv"
## 
## function (x, y) 
## standardGeneric("group_generic")
## <environment: 0x55c64847d440>
## Methods may be defined for arguments: x, y
## Use  showMethods(group_generic)  for currently available ones.
str(group_generic)
## Formal class 'groupGenericFunction' [package "methods"] with 9 slots
##   ..@ .Data       :function (x, y)  
##   ..@ groupMembers:List of 2
##   .. ..$ : chr "my_mult"
##   .. ..$ : chr "my_sum"
##   ..@ generic     : chr "group_generic"
##   .. ..- attr(*, "package")= chr ".GlobalEnv"
##   ..@ package     : chr ".GlobalEnv"
##   ..@ group       : list()
##   ..@ valueClass  : chr(0) 
##   ..@ signature   : chr [1:2] "x" "y"
##   ..@ default     : NULL
##   ..@ skeleton    : language (function (x, y)  stop("invalid call in method dispatch to 'group_generic' (no default method)",  ...
## [1] "my_mult"
## [1] "my_sum"

Объявление методов

Методы в системе S4 объявляются в более явном виде, чем в S3, с помощью специальной функции setMethod(), в которую аргументами передают название метода и класс, для которого объявляется метод.

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

setMethod(f = 'plot', signature = 'city', function(x) slotNames(x))
plot(msk)
## [1] "name"       "abb"        "population"

Аргумент f в данном случае используется для указания названия обобщённой функции, signature — для указания классов, для которых объявляется метод.

Отношения наследования классов также работают и с методами: метод, объявленный для суперкласса, будет работать и с унаследованными от него методами (если для них не объявлены собственные методы):

plot(vasileostrovsky)
## [1] "district_name" "district_abb"  "name"          "abb"          
## [5] "population"

В сигнатуре метода может быть более одного класса, притом самые разные: S3-/S4-классы, базовые типы данных ('numeric', 'character' и проч.), а также класс-заглушка 'ANY' (любой класс) и класс-исключение 'missing', который явно маркирует, какой аргумент не должен использоваться для диспетчеризации методов (если сигнатура метода не полностью соответствует сигнатуре дженерика).

Методы групп дженериков

Список объявленных методов для класса можно посмотреть с помощью функции methods(). Точно так же можно посмотреть все методы, связанные с какой-то обобщённой функцией.

# методы класса district
methods(class = 'district')
## [1] coerce      coerce<-    dummy_fun   dummy_fun2  from_method plot       
## see '?methods' for accessing help and source code
# методы функции dummy_fun2 (дженерик с одним методом)
methods(generic.function = 'dummy_fun2')
## [1] dummy_fun2,district-method
## see '?methods' for accessing help and source code

Вспомогательные методы

Нередко для большей дружелюбности пакета, основанного на S4-системе, создают вспомогательные методы для создания экземпляров классов или же доступа к слотам. Хэдли Викхэм, помимо этих двух задач, предлагает также создавать с помощью setValidity() функцию-валидатор. Валидатор используется для контроля, что при создании экземпляра класса в слоты передаются объекты корректного класса.

Пример функции для доступа к слотам (acсessor). Подобные функции идентичны оператору @, который используется для доступа к слотам, однако, как предполагается, более интуитивны:

# создаём дженерик и метод для извлечения аббревиатуры
setGeneric('abbreviation', function(x) setGeneric('abbreviation'))
## [1] "abbreviation"
setMethod('abbreviation', 'city', function(x) x@abb)

# пробуем извлечь аббревиатуру
abbreviation(msk)
## [1] "Msk"
abbreviation(vasileostrovsky)
## [1] "SPb"

Диспетчеризация методов

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

В S4 ситуация ощутимо сложнее. Это из-за того, что есть явная иерархия классов и множественное наследование сразу от нескольких классов и возможность использования сразу нескольких классов (в том числе и S3-классы) в сигнатуре метода. Всё это приводит к достаточно нетривиальной процедуре определения, метод какого класса должен быть использован в каждом соответствующем случае.

Основой для диспетчеризации методов в S4 является так называемое расстояние между классом объекта, который был передан в аргументы функции-дженерика, и тем классом, для которого определён метод. Расстоянием в данном случае считается количество поколений (родительских классов). Собственно, диспетчеризация методов — это поиск ближайшего определённого метода в иерархии классов. Притом поиск с учётом возможного множественного наследования и возможного различия классов в функциях нескольких переменных.

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

Самая тривиальная ситуация — когда класс имеет один суперкласс, который, в свою очередь, также унаследован от ещё какого-то суперкласса. То есть существует прямая иерархия классов. Например, если бы мы создали класс division муниципальных округов, унаследованный от district, то у нас образовалась бы цепочка классов division > distict > city. При такой схеме наследования диспетчеризация методов аналогична диспетчеризации в S3: когда мы применяем дженерик к объекту класса division, происходит поиск метода для него, и если такой метод не найден, — то вверх по иерархии до ближайшего родительского класса с определённым методом вплоть до самого общего суперкласса. Выше мы уже приводили пример такой схемы диспетчеризации, когда объявили метод abbreviation() для класса city, и он так же успешно отрабатывал на объекте унаследованного класса district.

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

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

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

Помимо задачи на вычисление сочетаний классов и методов, есть ещё несколько нюансов. Один из них — псевдоклассы missing и ANY, которые могут быть использованы при объявлении класса и метода. Метод класса ANY, если он объявлен, имеет меньший приоритет (считается всегда дальше, чем любой другой реальный класс, даже если формально в одном поколении) — это важно для решения амбивалентности при множественном наследовании.

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

R6

R6 и ReferenceClasses

Через некоторое время после введения в 1998 году системы S4 была попытка создать на её базе еще одну систему ООП, больше похожую на ООП менее функционально-ориентированных языков программирования. Эту систему назвали ReferenceClasses или, неформально, R5 (S3 и S4 как системы, пришедшие из S, а ReferenceClasses — уже в R). Позднее R5 выделилась в отдельную ветку, но развития не получила и чуть ли не после первого релиза (если он вообще состоялся) была заброшена. Система R6 идёт в той же логике именования, однако, в отличие от ReferenceClasses, не включена в ядро R и требует установки пакета R6. Другое отличие от ReferenceClasses/R5 - это опора на S3-классы, а не на S4. Это в какой-то мере упрощает использование R6, но все равно стоит признать, что R6-классы и методы могут быть несколько непривычны для многих R-пользователей.

Ключевое отличие ReferenceClasses/R5/R6 от S3- или S4- систем заключается в том, как организовано взаимодействие классов и методов. В S3/S4 есть функции, которые могут работать с разными классами (разные методы классов), обобщенные одной функцией (дженериком). При вызове дженерика происходит процесс диспетчеризации методов — определение, объект (объекты) какого класса передаются в функцию, и поиск соответствующего метода для этих классов. В R6 фокус не на функциях, а на классах: каждый класс имеет определённые для него методы, которые прописываются при создании класса или его дополнении. Соответственно, нет необходимости в функциях-дженериках и механизмах диспетчеризации, и на первый план выходят вопросы наследования классов, как эти классы организованы в памяти (изменяемость) и какие элементы класса могут быть доступны пользователю или другим функциям (инкапсуляция).

Объявление класса и методов

Для начала работы необходимо установить и подключить пакет R6:

Для объявления класса и методов нужна всего одна функция — R6Class(). При объявлении класса всегда должен создаваться объект (в S4, например, это необязательно). Обычно названия классов идут в UpperCamelCase-формате. Объекты, которые задаются при объявлении класса — это поля, а функции — методы этого класса.

Вот так выглядит простое объявление класса, описывающего город (название и аббревиатуру), без методов:

# подключаем пакет
library(R6)

# объявляем класс
City <- R6Class(
  classname = 'City',
  public = list(
    # поля (слоты) класса
    name = NA,
    abb = NA,
    country = 'Russia'
  )
)

Функция R6Class() создаёт объект-определение класса. Мы видим, что, помимо полей, есть ещё дополнительная информация, в частности, указатель окружения, в котором был объявлен класс, и параметры копирования и портирования класса между пакетами.

City
## <City> object generator
##   Public:
##     name: NA
##     abb: NA
##     country: Russia
##     clone: function (deep = FALSE) 
##   Parent env: <environment: R_GlobalEnv>
##   Locked objects: TRUE
##   Locked class: FALSE
##   Portable: TRUE

Создание экземпляра класса

Создать экземпляр класса можно с помощью встроенной функции $new() — это один из методов по умолчанию, которые объявляются для каждого R6-класса. Доступ к методам и полям класса осуществляется через оператор $ — то есть не просто вызов функции в виде new(R6ClassName), а R6ClassName$new():

msk <- City$new()
msk
## <City>
##   Public:
##     abb: NA
##     clone: function (deep = FALSE) 
##     country: Russia
##     name: NA

При выводе на печать содержания класса мы видим, что ни названия города, ни его аббревиатуры нет. Это логично, так как в функции $new() не предполагается дополнительных аргументов. Можно дополнительно присвоить значения соответствующим полям класса:

msk$name <- 'Moscow'
msk$abb <- 'Msk'
msk
## <City>
##   Public:
##     abb: Msk
##     clone: function (deep = FALSE) 
##     country: Russia
##     name: Moscow

Тем не менее, такой пайплайн работы с новыми классами не очень удобен. Поэтому рекомендуется объявлять публичный метод инициализации класса $initialize(), в котором аргументами задать значения необходимым полям.

Мы объявляем метод $initialize() с аргументами name и abb, по умолчанию они имеют значение NA (что позволит при инстанцировании класса указать только один из них). Объект self — это, по сути, ссылка на создаваемый экземпляр класса, что и обеспечивает запись значений в поля класса. Важный нюанс: при объявлении метода $initialize() можно в теле метода также написать объявление каких-либо функций.

# объявляем класс
City <- R6Class(
  classname = 'City',
  public = list(
    # поля (слоты) класса
    name = NA,
    abb = NA,
    country = 'Russia',
    
    # функция инициализации класса
    initialize = function(name = NA, abb = NA) {
      self$name <- name
      self$abb <- abb
    }
  )
)

Экземпляр класса, в котором объявлен метод $initialize(), создаётся всё так же с помощью $new(), однако тут уже можно будет указать аргументы — функция $new() просто вызовет $initialize() и передаст значения аргументов ей:

spb <- City$new(name = 'Saint-Petersburg', abb = 'SPb')
spb
## <City>
##   Public:
##     abb: SPb
##     clone: function (deep = FALSE) 
##     country: Russia
##     initialize: function (name = NA, abb = NA) 
##     name: Saint-Petersburg

Объявление методов

Методы в R6 — это функции, объявленные при определении класса. Упомянутые выше $new() и $initialize(), а также ряд других функций также являются методами, просто их наличие предполагается самой реализацией системы. Объявление пользовательских методов выглядит аналогичным образом.

Добавим при создании класса метод set_population(), который позволит задавать количество населения в городе и выводит на печать соответствующее сообщение:

# объявляем класс
City <- R6Class(
  classname = 'City',
  public = list(
    # поля (слоты) класса
    name = NA,
    abb = NA,
    country = 'Russia',
    population = NA,
    
    # метод для популяции
    set_population = function(x) {
      self$population <- x
      cat('added population', '\n')
    },
    
    # метод инициализации класса
    initialize = function(name = NA, abb = NA) {
      self$name <- name
      self$abb <- abb
    }
  )
)

Создаём экземпляр класса и проверяем, как работает метод:

spb <- City$new(name = 'Saint-Petersburg', abb = 'SPb')
spb$set_population(5)
## added population
spb
## <City>
##   Public:
##     abb: SPb
##     clone: function (deep = FALSE) 
##     country: Russia
##     initialize: function (name = NA, abb = NA) 
##     name: Saint-Petersburg
##     population: 5
##     set_population: function (x)

Добавление новых методов

В уже созданные классы добавить новые методы (да и поля тоже) можно с помощью функции $set(). Попробуем добавить в класс City пользовательский метод $print(), который будет выводить не структуру класса, а заданную нами строку. Метод print() уже определён для всех R6-классов, однако если мы зададим пользовательский метод, то он будет приоритетнее. Если же необходимо переписать уже существующий метод, то стоит воспользоваться аргументом overwrite = TRUE.

City$set('public', 'print', function(x) {
  msg <- paste0('city = ', self$name, ' (', self$abb, ')')
  cat(msg, '\n')
})
# создаём экземпляр класса
msk <- City$new('Moscow', 'Msk')

# вызываем метод
msk$print()
## city = Moscow (Msk)

Мы обновили определение класса City, добавив новый метод. Однако у нас уже был один созданный ранее экземпляр класса City. Если попробовать вызвать метод $print(), то мы получим ошибку.

spb$print()
## Error in eval(expr, envir, enclos): attempt to apply non-function

В подобных случаях необходимо пересоздать экземпляр класса:

spb <- City$new(name = 'Saint-Petersburg', abb = 'SPb')
spb$print()
## city = Saint-Petersburg (SPb)

Запретить добавление новых полей или методов можно с помощью аргумента lock_class = TRUE функции R6Class() либо же с помощью встроенных в каждый класс методов lock() / unlock(). Если распечатать объект-генератор заблокированного класса, то мы увидим строчки Locked objects: TRUE и Locked class: TRUE:

# защищаем класс от изменений
City$lock()

# смотрим результат
City
## <City> object generator
##   Public:
##     name: NA
##     abb: NA
##     country: Russia
##     population: NA
##     set_population: function (x) 
##     initialize: function (name = NA, abb = NA) 
##     clone: function (deep = FALSE) 
##     print: function (x) 
##   Parent env: <environment: R_GlobalEnv>
##   Locked objects: TRUE
##   Locked class: TRUE
##   Portable: TRUE
# пробуем добавить новое поле со значением 5
City$set('public', 'new_value', 5)
## Error in City$set("public", "new_value", 5): Can't modify a locked R6 class.
# снимаем защиту
City$unlock()

Взаимодействие с S3

Выше мы объявили метод $print(). Тем не менее, можно воспользоваться также и классической функцией print():

msk$print()
## city = Moscow (Msk)
print(msk)
## city = Moscow (Msk)

Несмотря на то, что обе функции вывода на печать возвращают одинаковый результат, механизм действия у них несколько разный. В выражении msk$print() происходит обращение напрямую к методу $print(). В выражении print(msk) сначала происходит анализ, объект какого класса надо напечатать, и только потом — вызов метода $print(). Это различие вызвано тем, что функция print() — S3-дженерик и при её вызове включается механизм диспетчеризации методов. Если же не объявлять метод $print() при создании класса, то будет использоваться определённый разработчиками S3-метод R6:::print.R6() (внутренний объект пакета R6).

Цепочки методов

Объявление методов в сочетании с механикой self позволяет делать так называемые цепочки методов. Ближайший аналог в R — разного рода пайпы, такие как magrittr::'%>%', ggplot2::'+' или чейнинг в data.table (выражения вида my_dt[][]). Другой пример — синтаксические конструкции в модуле pandas в Python, например: pd.my_df.groupby(col1)['col2'].sum().reset_index().

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

Изменим в нашем классе City оба пользовательских метода так, чтобы их можно было объединять в цепочку. Также добавим ещё и метод добавления аббревиатуры (по аналогии с методом добавления численности населения):

# объявляем класс
City <- R6Class(
  classname = 'City',
  public = list(
    # поля (слоты) класса
    name = NA,
    abb = NA,
    country = 'Russia',
    population = NULL,
    
    # метод для популяции
    set_population = function(x) {
      self$population <- x
      cat('added population', '\n')
      return(invisible(self))
    },
    
    # метод для аббревиатуры
    set_abb = function(x) {
      self$abb <- x
      cat('added abbreviation', '\n')
      return(invisible(self))
    },
    
    # метод для вывода на печать
    print = function() {
        msg <- paste0(
          'city = ', self$name, ' (', self$abb, '), population = ', self$population
        )
        cat(msg, '\n')
    },
    
    # метод инициализации класса
    initialize = function(name = NA, abb = NA) {
      self$name <- name
      self$abb <- abb
    }
  )
)

Пробуем цепочку:

# создаём экземпляр класса
nsk <- City$new(name = 'Novosibirsk')

# последовательно задаём аббревиатуру, население и выводим на печать
nsk$set_abb('Nsk')$set_population(1.4)$print()
## added abbreviation 
## added population 
## city = Novosibirsk (Nsk), population = 1.4

Код с цепочками методов можно организовывать в более структурированном виде:

nsk$
  set_abb('Nsk')$
  set_population(1.4)$
  print()
## added abbreviation 
## added population 
## city = Novosibirsk (Nsk), population = 1.4

Публичные и приватные методы

Наряду с публичными методами, в R6 можно задать и приватные методы и поля, которые будут недоступны для пользователя напрямую. Как правило, это позволяет оставить пользователю в доступе лишь несколько наиболее полезных методов, а остальные, в том числе и технические, скрыть. Приватный раздел задаётся с помощью аргумента private, приватные элементы доступны через обращение к private$element_name (по аналогии с self в публичном разделе).

Создадим метод, в котором в приватной части будет функция $loc(), выводящая сообщение, что это приватный метод. А в публичной части — метод, который обращается к приватному методу $loc():

# объявляем класс
Private <- R6Class("Private",
  private = list(
    loc = function() {
      message("it is private method")
    }
  ),
  public = list(
    call_private = function() {
      private$loc()
    }
  )
)
# создаём экземпляр класса
private_ex <- Private$new()

Если попробовать напрямую обратиться к методу $loc(), то мы получим ошибку, так как приватные методы недоступны для прямого обращения. Публичный метод, который также обращается к $loc(), работает:

private_ex$loc()
## Error in eval(expr, envir, enclos): attempt to apply non-function
private_ex$call_private()
## it is private method

Привязки значений

Одна из весьма необычных для большинства пользователей R механик — так называемые активные привязки (активный биндинг). Обычно, когда мы связываем объект и значение, вызвав название объекта, получаем значение:

x <- 5
x
## [1] 5

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

В базовом R активные привязки можно сделать с помощью makeActiveBinding(), в R6 под это отведен отдельный аргумент active функции R6Class(). При создании класса мы создаём поле distr, которое задаёт тип распределения, а в аргументе active формируем привязку:

Sampler <- R6Class(
  'Sampler',
  public = list(
    distr = NA,
    initialize = function(distr = 'rnorm') {
      self$distr <- get(distr)
    }
  ),
  active = list(
    rnd = function() self$distr(1)
  )
)

dist_sample <- Sampler$new('rlnorm')
dist_sample$rnd
## [1] 0.8121288

Несмотря на то, что rnd объявляется как функция, в результате из-за активной привязки это уже именно объект со своим классом:

class(dist_sample$rnd)
## [1] "numeric"

Ссылочная семантика

В отличие от подавляющего большинства R-конструкций, в R6 реализован доступ и хранение значений по ссылке (что, ко всему прочему, подразумевает изменяемость). Референсная семантика и изменяемость объектов в R6 возможны из-за лежащей в основе R6-классов работы с окружениями. Так, метод $new, если посмотреть код метода, создаёт отдельные окружения для публичного и приватного разделов.

Совместное использование объектов

Доступ к значениям по ссылке в некоторых случаях может приводить к ситуации совместного использования одного и того же объекта несколькими разными объектами. Примером может быть, когда класс в качестве поля содержит другой R6-класс (по сути, ссылку на значение в памяти):

# объявляем R6-класс
SimpleClass <- R6Class('SimpleClass',
  public = list(x = NULL)
)

# объявляем класс, в котором ранее объявленный класс 
# инстанцируется и записывается в поле e
SharedField <- R6Class('SharedField',
  public = list(
    e = SimpleClass$new()
  )
)

# создаём экземпляр класса и записываем значение
shared_1 <- SharedField$new()
shared_1$e$x <- 1

# создаём второй экземпляр класса и также записываем значение
shared_2 <- SharedField$new()
shared_2$e$x <- 2

# объект e$x оказывается совместно используемым
shared_1$e$x
## [1] 2
shared_2$e$x
## [1] 2

В документации по пакету (откуда взят пример) для избежания таких ситуаций рекомендуется объект e, в который записывается изменяемый тип, объявлять явно как элемент окружения (в данном случае self в методе инициализации класса):

# создаём класс с методом $initialize
NonSharedField <- R6Class('NonSharedField',
  public = list(
    e = NULL,
    initialize = function() self$e <- SimpleClass$new()
  )
)

# создаём экземпляр класса и записываем значение
non_shared_1 <- NonSharedField$new()
non_shared_1$e$x <- 1

# создаём второй экземпляр класса и записываем значение
non_shared_2 <- NonSharedField$new()
non_shared_2$e$x <- 2

# оба объекта e$x различаются
non_shared_1$e$x
## [1] 1
non_shared_2$e$x
## [1] 2

Копирование R6-классов

С другим примером специфичного поведения изменяемых объектов можно столкнуться при попытке скопировать объект R6-класса с последующим изменением копии:

# создаём класс с одним полем, в котором есть значение по умолчанию
Ref <- R6Class('Ref', public = list(value = 'origin'))

# создаём объект этого класса
ref_1 <- Ref$new()
ref_1$value
## [1] "origin"
# делаем копию объекта и меняем value
ref_2 <- ref_1
ref_2$value <- 'copy'

Смотрим результат. Из-за того, что ref_2 является не самостоятельным объектом, а только ссылкой на объект ref_1, поле value меняется и в ref_1, и в ref_2:

ref_1$value
## [1] "copy"
ref_2$value
## [1] "copy"

Для того чтобы правильно скопировать объект в новый объект, а не просто создать ссылку, следует воспользоваться методом $clone():

# создаём объект этого Ref
ref_1 <- Ref$new()

# делаем копию объекта и меняем value
ref_2 <- ref_1$clone()
ref_2$value <- 'copy'

# смотрим значение value в обоих объектах
ref_1$value
## [1] "origin"
ref_2$value
## [1] "copy"

Глубокое копирование

В тех ситуациях, когда в качестве значения поля или метода R6-класса выступает другой R6-объект, следует отдельно указывать, что нужна глубокая копия всех вложенных объектов — $clone(deep = TRUE). Впрочем, стоит учитывать, что такое глубокое копирование работает только с R6-классами и не работает с другими изменяемыми объектами (окружениями, референсными классами или объектами, которые включают в себя изменяемые объекты, наподобие списка нескольких R6-классов). В таких случаях необходимо написать собственный приватный метод $deep_clone(), в котором копируемые объекты будут преобразовываться в окружения и копироваться поэлементно.

Если объявлен метод $deep_clone(), то клонирование с $clone() с аргументом deep = TRUE приведёт к его вызову:

DeepCloneEnvs <- R6Class(
  'DeepCloneEnvs',
  public = list(
    a = NULL,
    v = 'regular value',
    initialize = function() {
      # записываем в поле a окружение
      self$a <- new.env(parent = emptyenv())
      # создаём в этом окружении объект x
      self$a$x <- 1
    }
  ),
  private = list(
    deep_clone = function(name, value) {
      if (is.environment(name)) {
        # если объект — окружение, то все элементы превращаем в список
        # и потом из списка создаём новое окружение
        list2env(as.list.environment(value, all.names = TRUE),
                 parent = emptyenv())
      } else {
        # или просто возвращаем значение
        value
      }
    }
  )
)

# создаём экземпляр класса
deep_clone_1 <- DeepCloneEnvs$new()

# клонируем его в новый объект
deep_clone_2 <- deep_clone_1$clone(deep = TRUE)

# в копии меняем значение x
deep_clone_2$a$x <- 2

# смотрим a$x в обоих объектах
deep_clone_1$a$x
## [1] 2
deep_clone_2$a$x
## [1] 2

Финализатор

Обычно рекомендуется аккуратно работать с рабочим пространством — закрывать коннекторы к базе данных, удалять лишние объекты и так далее. В R есть собственный сборщик мусора, а также есть финализаторы, которые в принудительном порядке удаляют ссылки на блоки в памяти. Например, в base R есть функция reg.finalizer(), которая вызывается при закрытии R-сессии.

В R6 также есть собственный финализатор, который срабатывает при вызове сборщика мусора. Обычно метод $finalize() объявляют в приватной части класса (хотя можно и в публичной). Если объявлять этот метод с аргументом on.exit = TRUE, то финализатор будет вызываться при завершении сессии.

Простейший пример: объявляем класс, в котором есть единственный метод — финализатор. При вызове этого метода на печать должна быть выведена надпись Finalizer has been called! (пример взят из документации по R6). Для того чтобы финализатор отработал, необходимо вызвать сборщик мусора:

# объявляем класс с одним приватным методом
Finish <- R6Class(
  'Finish', 
  private = list(
    finalize = function() {
      print('finished him!')
      }
  )
)

# создаём экземпляр класса
finish_object <- Finish$new()

# удаляем объект
rm(finish_object)
exists(finish_object)
## Error in exists(finish_object): object 'finish_object' not found
# принудительно вызываем сборщик мусора
gc()
## [1] "finished him!"
##           used (Mb) gc trigger (Mb) max used (Mb)
## Ncells  855367 45.7    1671152 89.3  1212712 64.8
## Vcells 1645494 12.6    8388608 64.0  2356444 18.0

Наследование классов

Как и в большинстве систем ООП, в R6 реализовано наследование классов. Суперкласс указывается через аргумент inherit, в котором указывается объект-определение суперкласса. К методам суперкласса можно обращаться через объект super (по аналогии с self и private).

Создадим класс District, который будет наследовать классу City. Как мы видим, в списке полей есть не только поля объявленного класса, но и поля суперкласса.

District <- R6Class(
  'District',
  inherit = City,
  public = list(
    d_name = NA,
    # метод инициализации класса
    initialize = function(d_name = NA) {
      self$d_name <- d_name
    },
    # метод для вывода на печать
    print = function() {
        msg <- paste0('district = ', self$d_name)
        cat(msg, '\n')
    }
  )
)

# создаём экземпляр класса
vasileostrovsky <- District$new('Vasileostrovsky')

Если посмотреть структуру объекта класса District, видно, что он содержит как собственные поля (d_name), так и унаследованные поля и методы (name, abb, population, set_abb, set_population).

str(vasileostrovsky)
## Classes 'District', 'City', 'R6' <District>
##   Inherits from: <City>
##   Public:
##     abb: NA
##     clone: function (deep = FALSE) 
##     country: Russia
##     d_name: Vasileostrovsky
##     initialize: function (d_name = NA) 
##     name: NA
##     population: NULL
##     print: function () 
##     set_abb: function (x) 
##     set_population: function (x)

Функция $print() определена для класса District, поэтому она не наследуется от City и имеет собственное поведение:

vasileostrovsky$print()
## district = Vasileostrovsky

Портируемость классов

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

Пример из документации:

# создаём два окружения для симуляции поведения пакетов
pkgA <- new.env()
pkgB <- new.env()

# создаём функцию в одном из окружений
pkgA$fun <- function() cat('Fun from pkgA', '\n')

# создаём класс в окружении pkgA
ClassA <- R6Class('ClassA',
  portable = FALSE,
  public = list(
    pkg_fun = function() fun()
  ),
  parent_env = pkgA
)

# создаём класс, который унаследован от pkgA
ClassB <- R6Class('ClassB',
  portable = FALSE,
  inherit = ClassA,
  parent_env = pkgB
)

# создаём экземпляры классов
s_class <- ClassA$new()
c_class <- ClassB$new()

# тестируем метод $pkg_fun()
s_class$pkg_fun()
## Fun from pkgA
c_class$pkg_fun()
## Error in fun(): could not find function "fun"

В этом примере видно, что унаследованный класс ClassB не может использовать функцию fun(), так как она была объявлена в окружении, где был создан суперкласс ClassA, и в процессе наследования никак не участвует. Это синтетический пример, так явно выставлен параметр portable = FALSE. По умолчанию задано значение TRUE, в результате если в унаследованном классе вызывается какой-то метод суперкласса (по цепочке наследований), то он выполняется в окружении суперкласса.

Тот же самый пример, в котором разрешена портируемость классов (portable = TRUE). Как мы видим, функция fun() из окружения pkgA вполне может быть использована в объекте класса ClassB:

# создаём два окружения для симуляции поведения пакетов
pkgA <- new.env()
pkgB <- new.env()

# создаём функцию в одном из окружений
pkgA$fun <- function() cat('Fun from pkgA', '\n')

# создаём класс в окружении pkgA
ClassA <- R6Class('ClassA',
  public = list(
    pkg_fun = function() fun()
  ),
  parent_env = pkgA
)

# создаём класс, который унаследован от pkgA
ClassB <- R6Class('ClassB',
  inherit = ClassA,
  parent_env = pkgB
)

# создаём экземпляры классов
s_class <- ClassA$new()
c_class <- ClassB$new()

# тестируем метод $pkg_fun()
s_class$pkg_fun()
## Fun from pkgA
c_class$pkg_fun()
## Fun from pkgA