この vignette では、型安定性とサイズ安定性の考え方を紹介します。これらの特性を持つ関数は、出力の「形」を予測するためには、入力の「形」を知るだけでよいので、推論が大幅に容易になります。
この研究は、コードをレビューする際に気付いた共通のパターンが動機の一つとなっています。つまり、(実行せずに)コードを読んで、各変数の型を予測できない場合、そのコードに対して非常に不安を感じます。この感覚は重要です。というのも、ほとんどのユニットテストでは、奇妙で変わったものを徹底的にテストするのではなく、典型的な入力を調べるからです。変数の型(とサイズ)を分析することで、不快なエッジケースを発見することができます。
(訳注: iff = “if and only if”)
以下の場合にのみ、ある関数は type-stable であると言います。
入力の型だけを知っていれば、出力の型を予測できる。
…の引数の順序は出力型に影響しない。
同様に、次のような場合にのみ、関数は size-stable であると言います。
Rの基本的な関数でサイズが安定しているものはほとんどありませんので、もう少し弱い条件も定義しておきます。以下の条件を満たす関数を length-stable と呼ぶことにします。
(ただし、length()
はベクトルではないものにも値を返すので、length-stable は特に強固な定義ではないことに注意してください)。
これらの原則に従わない関数を、それぞれ type-unstable 、 size-unstable と呼びます。
型とサイズの安定性に加えて、一貫して適用される単一のルールを持つことも望ましいことです。異なる関数に適用される多くのルールではなく、どこにでも適用される1つの型強制やサイズ再利用のルールが欲しいのです。
これらの原則の目的は、認知的なオーバーヘッドを最小限に抑えることです。多くの特殊なケースを覚えるのではなく、1つの原則を覚えて、それを繰り返し適用できるようにすべきです。
これらの考え方を具体的にするために、いくつかの基本的な機能に当てはめてみましょう。
mean()
は,常に長さ1の double ベクトルを返すので,些細なことですが型安定性とサイズ安定性があります (あるいはエラーを投げる)。
意外なことに、median()
は型が安定していません。
vec_ptype_show(median(c(1L, 1L)))
#> Prototype: double
vec_ptype_show(median(c(1L, 1L, 1L)))
#> Prototype: integer
ただし、常に長さ1のベクトルを返すので、サイズには安定しています。
sapply()
は、入力の型を知っているだけでは出力の型を予測できないので、type-untable です。 予測できないからです。
vec_ptype_show(sapply(1L, function(x) c(x, x)))
#> Prototype: integer[,1]
vec_ptype_show(sapply(integer(), function(x) c(x, x)))
#> Prototype: list
これは、size-stable というわけではありません. ベクトルに対しては vec_size(sapply(x, f))
は vec_size(x)
となりますが、行列(出力は転置されます)やデータフレーム(列を繰り返します)ではこうなりません。
vapply()
は sapply()
の型安定版です。 vec_ptype_show(vapply(x, fn, template))
は常に vec_ptype_show(template)
です。 これは sapply()
と同じ理由でサイズ的に不安定です。
c()
は、c(x, y)
がc(y, x)
と同じ型を出力するとは限らないので、type-unstable です。
vec_ptype_show(c(NA, Sys.Date()))
#> Prototype: double
vec_ptype_show(c(Sys.Date(), NA))
#> Prototype: date
c()
は、ほとんど常に length-stable です。 length(c(x, y))
は ほとんど常に、 length(x) + length(y)
に等しいからです。一般的な不安定要因として ここでの不安定さの原因の1つは、非ベクトルを扱うことです(後のセクション「非ベクトル」を参照)。
paste(x1, x2)
は length-stable です。なぜなら、 length(paste(x1, x2))
が max(length(x1), length(x2))
と等しいからです。しかし、 paste(1:2, 1:3)
が警告を出さないため、通常の計算リサイクルとは異なります。
ifelse()
は length-stable です。なぜなら、 length(ifelse(cond, true, false))
が常に length(cond)
だからです。 出力型が cond
型に依存するため、 ifelse()
は type-unstable となります。:
read.csv(file)
は type-unstable かつ size-unstable となります。 なぜなら、データフレームを返すことはわかっていますが、どの列に返すか、 またいくつの行返すかが分からないからです。 同様に、 df[[i]]
は、 結果が i
の 値 に依存するため、 type-stable ではありません。 type-stable や size-stable にできない重要な関数も多くあります!
型安定性とサイズ安定性を理解した上で、それらを使ってRの基本的な関数をより深く分析し、より優れた特性を持つ代替品を提案します。
c()
and vctrs::vec_c()
本節では、c()
とvec_c()
を比較対照します。vec_c()`は以下のような不変量を持っているので、型とサイズの両方に安定しています。
vec_ptype(vec_c(x, y))
equals vec_ptype_common(x, y)
.vec_size(vec_c(x, y))
equals vec_size(x) + vec_size(y)
.c()
には、unlist()
との整合性がないという、もう一つの望ましくない特性があります。 つまり、unlist(list(x, y))
は常に c(x, y)
と一致するわけではありません。つまり、ベースRには複数の型強制規則が存在するのです。この問題については、ここではこれ以上考えません。
ここには2つの目標があります。
c()
の癖を完全に文書化して、代替品の開発のモチベーションを上げる。 代替案の開発を動機付けること。
上記の型安定性とサイズ安定性の明白でない結果を議論すること。
アトムベクトルだけを考えると,c()
は,character > complex > double > integer > logical という型の階層を使っているので,型安定性があります.
vec_c()
は、同じようにふるまいます:
しかし、それは自動的に文字のベクトルやリストに強制するものではありません。
一般に、base のほとんどのメソッドはエラーを返しません。:
入力がベクトルでない場合、c()
は自動的にリストにします。
c(mean, globalenv())
#> [[1]]
#> function (x, ...)
#> UseMethod("mean")
#> <bytecode: 0x00000000161a6b68>
#> <environment: namespace:base>
#>
#> [[2]]
#> <environment: R_GlobalEnv>
数字のバージョンの場合、これは入力の順序に依存します。バージョンが最初の場合はエラーとなり、そうでない場合は入力がリストに包まれます。
c(getRversion(), "x")
#> Error: invalid version specification 'x'
c("x", getRversion())
#> [[1]]
#> [1] "x"
#>
#> [[2]]
#> [1] 3 6 3
vec_c()
は,入力がベクトルでない場合や自動的に強制にならない場合にエラーを投げます。
2つの因子型 (factor) から integer vector を返します。
(This is documented in c()
but is still undesirable.)
vec_c()
は、レベルの結合を取る因子を返します。base Rには文字ベクトルを自動的に因子に変換する箇所が多く、より厳密な動作をさせることは不必要に負担になるからです。(これは、より厳密であり、ユーザを悩ませる原因となっている dplyr::bind_rows()
の経験によって裏付けられています)。
c()
は、date-time に関連付けられたタイムゾーンを削除します。:
datetime_nz <- as.POSIXct("2020-01-01 09:00", tz = "Pacific/Auckland")
c(datetime_nz)
#> [1] "2020-01-01 05:00:00 JST"
この動作は ?DateTimeClasses
に記述されていますが、ユーザーにとってはかなりの苦痛の種となっています。
vec_c()
はタイムゾーンを保持します。
入力のタイムゾーンが異なる場合、出力のタイムゾーンはどのようにすべきでしょうか? 選択肢の一つは、厳密に、ユーザーがすべてのタイムゾーンを手動で調整することです。しかし、これは負担が大きいので(特に base R にはタイムゾーンを変更する簡単な方法がないので)、vctrs は最初の非ローカルなタイムゾーンを使用することを選択します。
datetime_local <- as.POSIXct("2020-01-01 09:00")
datetime_houston <- as.POSIXct("2020-01-01 09:00", tz = "US/Central")
vec_c(datetime_local, datetime_houston, datetime_nz)
#> [1] "2019-12-31 18:00:00 CST" "2020-01-01 09:00:00 CST"
#> [3] "2019-12-31 14:00:00 CST"
vec_c(datetime_houston, datetime_nz)
#> [1] "2020-01-01 09:00:00 CST" "2019-12-31 14:00:00 CST"
vec_c(datetime_nz, datetime_houston)
#> [1] "2020-01-01 09:00:00 NZDT" "2020-01-02 04:00:00 NZDT"
date と date-time を c()
で結合すると、静かに不正な結果を返します。
date <- as.Date("2020-01-01")
datetime <- as.POSIXct("2020-01-01 09:00")
c(date, datetime)
#> [1] "2020-01-01" "4321940-06-07"
c(datetime, date)
#> [1] "2020-01-01 09:00:00 JST" "1970-01-01 14:04:22 JST"
この動作は,c.Date()
やc.POSIXct()
が,すべての入力が同じ型であるかどうかをチェックしていないために生じるものです。
vec_c()
はこの問題を回避するために標準的なルールを使用します。 date と date-time を混在させた場合,vctrs は date-time を返し、date を(date-time のタイムゾーンの)午前0時の date-time に変換します。
入力の最初に欠落した値が来ると、c()
はすべての属性を除去する内部動作にフォールバックします。
vec_c()
では、NA
だけで構成される論理ベクトルを、他の1d型に変換可能な unspecified()
クラスとして扱うという、異なるアプローチをとっています。
c()
は、ほとんど常に length-stable なので、データフレームを列ごとに(リストに)まとめることができます。
df1 <- data.frame(x = 1)
df2 <- data.frame(x = 2)
str(c(df1, df1))
#> List of 2
#> $ x: num 1
#> $ x: num 1
vec_c()
は size-stable なので、データフレームを行バインドすることができます。
同様の理由で、行列 (matrix) にも適用されます。
m <- matrix(1:4, nrow = 2)
c(m, m)
#> [1] 1 2 3 4 1 2 3 4
vec_c(m, m)
#> [,1] [,2]
#> [1,] 1 3
#> [2,] 2 4
#> [3,] 1 3
#> [4,] 2 4
一つの違いは,vec_c()
が行列の次元に合わせてベクトルを “ブロードキャスト”することです.
vec_c()
の基本的な実装は合理的にシンプルです。まず、出力のプロパティ、つまり共通の型と総サイズを把握し、vec_init()
でそれを割り当て、各入力を出力の正しい場所に挿入します。
vec_c <- function(...) {
args <- compact(list2(...))
ptype <- vec_ptype_common(!!!args)
if (is.null(ptype))
return(NULL)
ns <- map_int(args, vec_size)
out <- vec_init(ptype, sum(ns))
pos <- 1
for (i in seq_along(ns)) {
n <- ns[[i]]
x <- vec_cast(args[[i]], to = ptype)
vec_slice(out, pos:(pos + n - 1)) <- x
pos <- pos + n
}
out
}
(The real vec_c()
is a bit more complicated in order to handle inner and outer names).
ifelse()
vctrs の開発の動機となった関数の一つにifelse()
があります。これは、結果値が「test
と同じ長さと属性(次元とクラスを含む)を持つベクトル」という驚くべき性質を持っています。私には、出力の種類が yes
と no
の引数の種類によって制御されることの方が合理的に思えます。
dplyr::if_else()
では、厳密さを求めすぎて、yes
と no
が同じ型でない場合にエラーを出しています。これは、型付けされた欠損値(NA_character_
など)を必要とするため、実際には迷惑なことです。また、チェックは(完全なプロトタイプではなく)クラスに対してのみ行われるため、無効な出力を簡単に作ることができます。
型安定性とサイズ安定性の考え方を理解すると、ifelse()
が何をすべきかをより簡単に理解することができました。
第1引数は論理的でなければなりません。
vec_ptype(if_else(test, yes, no))
は、vec_ptype_common(yes, no)
と等しくなります。 if_else()
とは異なり、これは次のことを意味します。 これは、if_else()
が正しい型を判断するために、常に yes
と no
の両方を評価しなければならないことを意味します。 正しい型を判断するために、yes
とno
の両方を常に評価しなければならないことを意味します。これは &&
(スカラー演算、短絡)や &
(ベクトル演算、両側を評価) と一致していると思います。 これは、&&
(スカラー演算、短絡的)と&
(ベクトル化、両辺を評価)との一貫性があると思います。
vec_size(if_else(test, yes, no))
は、vec_size_common(test, yes, no)
になります。 出力は test
と同じサイズ(つまり、ifelse
と同じ動作)になると思いますが、 一般的なルールとして、入力は相互にリサイクル可能かどうでないかであるべきだと思います。
これにより、以下のような実装になります。
if_else <- function(test, yes, no) {
vec_assert(test, logical())
c(yes, no) %<-% vec_cast_common(yes, no)
c(test, yes, no) %<-% vec_recycle_common(test, yes, no)
out <- vec_init(yes, vec_size(yes))
vec_slice(out, test) <- vec_slice(yes, test)
vec_slice(out, !test) <- vec_slice(no, !test)
out
}
x <- c(NA, 1:4)
if_else(x > 2, "small", "big")
#> [1] NA "big" "big" "small" "small"
if_else(x > 2, factor("small"), factor("big"))
#> [1] <NA> big big small small
#> Levels: small big
if_else(x > 2, Sys.Date(), Sys.Date() + 7)
#> [1] NA "2021-04-15" "2021-04-15" "2021-04-08" "2021-04-08"
vec_size()
と vec_slice()
を使うことで、この if_else()
の定義は自動的にデータフレームや行列に対応します。