Type and size stability

この vignette では、型安定性とサイズ安定性の考え方を紹介します。これらの特性を持つ関数は、出力の「形」を予測するためには、入力の「形」を知るだけでよいので、推論が大幅に容易になります。

この研究は、コードをレビューする際に気付いた共通のパターンが動機の一つとなっています。つまり、(実行せずに)コードを読んで、各変数の型を予測できない場合、そのコードに対して非常に不安を感じます。この感覚は重要です。というのも、ほとんどのユニットテストでは、奇妙で変わったものを徹底的にテストするのではなく、典型的な入力を調べるからです。変数の型(とサイズ)を分析することで、不快なエッジケースを発見することができます。

library(vctrs)
library(zeallot)

定義

(訳注: iff = “if and only if”)

以下の場合にのみ、ある関数は type-stable であると言います。

  1. 入力の型だけを知っていれば、出力の型を予測できる。

  2. …の引数の順序は出力型に影響しない。

同様に、次のような場合にのみ、関数は size-stable であると言います。

  1. 入力の大きさだけで出力の大きさを予測できる、または 出力サイズを指定する数値入力が1つある。

Rの基本的な関数でサイズが安定しているものはほとんどありませんので、もう少し弱い条件も定義しておきます。以下の条件を満たす関数を length-stable と呼ぶことにします。

  1. 入力の長さだけで出力の長さを予測できる、または 出力 length を指定する単一の数値入力がある。

(ただし、length()はベクトルではないものにも値を返すので、length-stable は特に強固な定義ではないことに注意してください)。

これらの原則に従わない関数を、それぞれ type-unstablesize-unstable と呼びます。

型とサイズの安定性に加えて、一貫して適用される単一のルールを持つことも望ましいことです。異なる関数に適用される多くのルールではなく、どこにでも適用される1つの型強制やサイズ再利用のルールが欲しいのです。

これらの原則の目的は、認知的なオーバーヘッドを最小限に抑えることです。多くの特殊なケースを覚えるのではなく、1つの原則を覚えて、それを繰り返し適用できるようにすべきです。

これらの考え方を具体的にするために、いくつかの基本的な機能に当てはめてみましょう。

  1. mean() は,常に長さ1の double ベクトルを返すので,些細なことですが型安定性とサイズ安定性があります (あるいはエラーを投げる)。

  2. 意外なことに、median() は型が安定していません。

    ただし、常に長さ1のベクトルを返すので、サイズには安定しています。

  3. sapply() は、入力の型を知っているだけでは出力の型を予測できないので、type-untable です。 予測できないからです。

    これは、size-stable というわけではありません. ベクトルに対しては vec_size(sapply(x, f))vec_size(x) となりますが、行列(出力は転置されます)やデータフレーム(列を繰り返します)ではこうなりません。

  4. vapply()sapply() の型安定版です。 vec_ptype_show(vapply(x, fn, template)) は常に vec_ptype_show(template) です。 これは sapply() と同じ理由でサイズ的に不安定です。

  5. c()は、c(x, y)c(y, x)と同じ型を出力するとは限らないので、type-unstable です。

    c()は、ほとんど常に length-stable です。 length(c(x, y))ほとんど常にlength(x) + length(y) に等しいからです。一般的な不安定要因として ここでの不安定さの原因の1つは、非ベクトルを扱うことです(後のセクション「非ベクトル」を参照)。

  6. paste(x1, x2) は length-stable です。なぜなら、 length(paste(x1, x2))max(length(x1), length(x2)) と等しいからです。しかし、 paste(1:2, 1:3) が警告を出さないため、通常の計算リサイクルとは異なります。

  7. ifelse() は length-stable です。なぜなら、 length(ifelse(cond, true, false)) が常に length(cond) だからです。 出力型が cond 型に依存するため、 ifelse() は type-unstable となります。:

  8. 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()`は以下のような不変量を持っているので、型とサイズの両方に安定しています。

c()には、unlist()との整合性がないという、もう一つの望ましくない特性があります。 つまり、unlist(list(x, y)) は常に c(x, y) と一致するわけではありません。つまり、ベースRには複数の型強制規則が存在するのです。この問題については、ここではこれ以上考えません。

ここには2つの目標があります。

Atomic vectors

アトムベクトルだけを考えると,c()は,character > complex > double > integer > logical という型の階層を使っているので,型安定性があります.

vec_c() は、同じようにふるまいます:

しかし、それは自動的に文字のベクトルやリストに強制するものではありません。

互換性のないベクトルと非ベクトル

一般に、base のほとんどのメソッドはエラーを返しません。:

入力がベクトルでない場合、c() は自動的にリストにします。

数字のバージョンの場合、これは入力の順序に依存します。バージョンが最初の場合はエラーとなり、そうでない場合は入力がリストに包まれます。

vec_c() は,入力がベクトルでない場合や自動的に強制にならない場合にエラーを投げます。

Factors

2つの因子型 (factor) から integer vector を返します。

(This is documented in c() but is still undesirable.)

vec_c()は、レベルの結合を取る因子を返します。base Rには文字ベクトルを自動的に因子に変換する箇所が多く、より厳密な動作をさせることは不必要に負担になるからです。(これは、より厳密であり、ユーザを悩ませる原因となっている dplyr::bind_rows() の経験によって裏付けられています)。

date-time 型

c()は、date-time に関連付けられたタイムゾーンを削除します。:

この動作は ?DateTimeClasses に記述されていますが、ユーザーにとってはかなりの苦痛の種となっています。

vec_c() はタイムゾーンを保持します。

入力のタイムゾーンが異なる場合、出力のタイムゾーンはどのようにすべきでしょうか? 選択肢の一つは、厳密に、ユーザーがすべてのタイムゾーンを手動で調整することです。しかし、これは負担が大きいので(特に base R にはタイムゾーンを変更する簡単な方法がないので)、vctrs は最初の非ローカルなタイムゾーンを使用することを選択します。

date 型と date-time 型

date と date-time を c() で結合すると、静かに不正な結果を返します。

この動作は,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 なので、データフレームを列ごとに(リストに)まとめることができます。

vec_c() は size-stable なので、データフレームを行バインドすることができます。

行列と配列

同様の理由で、行列 (matrix) にも適用されます。

一つの違いは,vec_c() が行列の次元に合わせてベクトルを “ブロードキャスト”することです.

実装

vec_c() の基本的な実装は合理的にシンプルです。まず、出力のプロパティ、つまり共通の型と総サイズを把握し、vec_init() でそれを割り当て、各入力を出力の正しい場所に挿入します。

(The real vec_c() is a bit more complicated in order to handle inner and outer names).

ifelse()

vctrs の開発の動機となった関数の一つにifelse()があります。これは、結果値が「testと同じ長さと属性(次元とクラスを含む)を持つベクトル」という驚くべき性質を持っています。私には、出力の種類が yesno の引数の種類によって制御されることの方が合理的に思えます。

dplyr::if_else() では、厳密さを求めすぎて、yesno が同じ型でない場合にエラーを出しています。これは、型付けされた欠損値(NA_character_など)を必要とするため、実際には迷惑なことです。また、チェックは(完全なプロトタイプではなく)クラスに対してのみ行われるため、無効な出力を簡単に作ることができます。

型安定性とサイズ安定性の考え方を理解すると、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() の定義は自動的にデータフレームや行列に対応します。

if_else(x > 2, data.frame(x = 1), data.frame(y = 2))
#>    x  y
#> 1 NA NA
#> 2 NA  2
#> 3 NA  2
#> 4  1 NA
#> 5  1 NA

if_else(x > 2, matrix(1:10, ncol = 2), cbind(30, 30))
#>      [,1] [,2]
#> [1,]   NA   NA
#> [2,]   30   30
#> [3,]   30   30
#> [4,]    4    9
#> [5,]    5   10