diff --git a/NAMESPACE b/NAMESPACE index 9e4c92c73..2fca5a960 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -487,6 +487,7 @@ export(list_combine) export(list_drop_empty) export(list_of) export(list_sizes) +export(list_transpose) export(list_unchop) export(maybe_lossy_cast) export(n_fields) diff --git a/NEWS.md b/NEWS.md index ca3aa80cd..41c632bda 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # vctrs (development version) +* New `list_transpose()` for transposing a list of vectors (#2059). + * `vec_interleave()` gains new `.size` and `.error_call` arguments. * `vec_interleave()` now reports the correct index in errors when `NULL`s are present. diff --git a/R/list-transpose.R b/R/list-transpose.R new file mode 100644 index 000000000..d91784d17 --- /dev/null +++ b/R/list-transpose.R @@ -0,0 +1,297 @@ +#' Transpose a list of vectors +#' +#' @description +#' `list_transpose()` takes a list of vectors, transposes it, and returns a new +#' list of vectors. +#' +#' To predict the output from `list_transpose()`, swap the size of the list +#' with the size of the list elements. For example: +#' +#' - Input: List of size 2, elements of size 3 +#' - Output: List of size 3, elements of size 2 +#' +#' @inheritParams rlang::args_dots_empty +#' @inheritParams rlang::args_error_context +#' +#' @param x A list of vectors. +#' +#' - Each vector will be [recycled][theory-faq-recycling] to the common size +#' before transposing. +#' +#' - Each vector will be [cast][theory-faq-coercion] to the common type before +#' transposing. +#' +#' @param null A value to replace `NULL` elements with before transposing. +#' +#' If left unspecified, any `NULL` elements in `x` result in an error. +#' +#' If specified: +#' +#' - Will be [recycled][theory-faq-recycling] to the common size of `x` before +#' transposing. +#' +#' - Will participate in determining the common type, and will be +#' [cast][theory-faq-coercion] to that type before transposing. +#' +#' Note that `null` can alter the output type, but cannot alter the output +#' size. See the examples for consequences of this. +#' +#' @param size The expected size of each element of `x`. If not provided, +#' computed automatically by [vec_size_common()]. +#' +#' @param ptype The expected type of each element of `x`. If not provided, +#' computed automatically by [vec_ptype_common()]. +#' +#' @param x_arg Argument name used in error messages. +#' +#' @returns +#' A list of vectors with the following invariants: +#' +#' For the list: +#' +#' - `vec_ptype(list_transpose(x)) == ` +#' - `vec_size(list_transpose(x)) == vec_size_common(!!!x)` +#' +#' For the list elements: +#' +#' - `vec_ptype(list_transpose(x)[[i]]) == vec_ptype_common(!!!x)` +#' - `vec_size(list_transpose(x)[[i]]) == vec_size(x)` +#' +#' @export +#' @examples +#' # I: List size 3, Element size 2 +#' # O: List size 2, Element size 3 +#' list_transpose(list(1:2, 3:4, 5:6)) +#' +#' # With data frames +#' x <- data_frame(a = 1:2, b = letters[1:2]) +#' y <- data_frame(a = 3:4, b = letters[3:4]) +#' list_transpose(list(x, y)) +#' +#' # Size 1 elements are recycled +#' list_transpose(list(1, 2:3, 4)) +#' +#' # --------------------------------------------------------------------------- +#' # Using `size` and `ptype` +#' +#' # With size 0 elements, the invariants are a bit tricky! +#' # This must return a size 0 list, but then you lose expected +#' # type (integer) and size (2) information about the elements. +#' # Losing that information makes it difficult to reverse the +#' # transposition. +#' # +#' # I: List size 2, Element size 0 +#' # O: List size 0, Element size 2 +#' x <- list(integer(), integer()) +#' out <- list_transpose(x) +#' out +#' +#' # Note how transposing a second time doesn't recover the original list +#' list_transpose(out) +#' +#' # To work around this, provide the lost `size` and `ptype` manually +#' list_transpose(out, size = vec_size(x), ptype = vec_ptype_common(!!!x)) +#' +#' # --------------------------------------------------------------------------- +#' # Padding +#' +#' # If you'd like to pad with a missing value rather than erroring, +#' # you might do something like this, which left-pads +#' x <- list(1, 2:5, 6:7) +#' try(list_transpose(x)) +#' +#' sizes <- list_sizes(x) +#' size <- max(sizes) +#' index <- which(sizes != size) +#' +#' x[index] <- lapply( +#' index, +#' function(i) vec_c(rep(NA, times = size - sizes[[i]]), x[[i]]) +#' ) +#' x +#' +#' list_transpose(x) +#' +#' # --------------------------------------------------------------------------- +#' # `NULL` handling +#' +#' # `NULL` values aren't allowed in `list_transpose()` +#' x <- list(1:3, NULL, 5:7, NULL) +#' try(list_transpose(x)) +#' +#' # Replace them with `null` +#' list_transpose(x, null = NA) +#' list_transpose(x, null = -(1:3)) +#' +#' # When you don't know the list element size up front, but you still want +#' # to replace `NULL`s with something, use a size 1 `null` value which will +#' # get recycled to the element size after it has been computed +#' list_transpose(list(), null = NA) +#' list_transpose(list(1, NULL, 3), null = NA) +#' list_transpose(list(1, NULL, 3:4), null = NA) +#' +#' # When you do know the list element size up front, it's best to also provide +#' # that information as `size`, as this helps direct the recycling process +#' # for `null`, particularly in the cases of an empty list, a list of `NULL`s, +#' # or a list of size 1 elements. You typically know the list element size if +#' # you are providing a `null` of size != 1, because otherwise you wouldn't +#' # have been able to make `null` in the first place! +#' size <- 2L +#' null <- 3:4 +#' +#' # `size` overrides the inferred element size of 0 +#' # +#' # I: List size 0, Element size 0 +#' # O: List size 0, Element size 0 +#' try(list_transpose(list(), null = null)) +#' # I: List size 0, Element size 2 +#' # O: List size 2, Element size 0 +#' list_transpose(list(), null = null, size = size) +#' +#' # Same idea here +#' # +#' # I: List size 1, Element size 0 +#' # O: List size 0, Element size 1 +#' try(list_transpose(list(NULL), null = null)) +#' # I: List size 1, Element size 2 +#' # O: List size 2, Element size 1 +#' list_transpose(list(NULL), null = null, size = size) +#' +#' # `size` overrides the inferred element size of 1 +#' # +#' # I: List size 2, Element size 1 +#' # O: List size 1, Element size 2 +#' try(list_transpose(list(1, 2), null = null)) +#' # I: List size 2, Element size 2 +#' # O: List size 2, Element size 2 +#' list_transpose(list(1, 2), null = null, size = size) +#' +#' # The reason that `list_transpose()` recycles `null` to the common size +#' # rather than letting `null` participate in common size determination is +#' # due to this example. When supplying a size 1 `null`, most of the time +#' # you don't know the element size, and you just want `null` to recycle to +#' # whatever the required size will be. If `null` participated in determining +#' # the common size, the output of this would be `list(logical())` rather than +#' # `list()` because the element size would be computed as 1. Since a size 1 +#' # `null` is much more common than a size !=1 `null`, we've optimized for this +#' # case at the cost of needing to specify `size` explicitly in some scenarios. +#' list_transpose(list(), null = NA) +list_transpose <- function( + x, + ..., + null = NULL, + size = NULL, + ptype = NULL, + x_arg = caller_arg(x), + error_call = current_env() +) { + check_dots_empty0(...) + + obj_check_list(x, arg = x_arg, call = error_call) + + # Disallow `NULL` elements if the user isn't replacing them with something + list_check_all_vectors( + x, + allow_null = !is_null(null), + arg = x_arg, + call = error_call + ) + + # `size` only comes from `x` and `size`. + # `null` is recycled to this size but doesn't contribute to it! + size <- vec_size_common( + !!!x, + .size = size, + .arg = x_arg, + .call = error_call + ) + + # `ptype` comes from `x`, `null`, and `ptype` + ptype <- list_transpose_ptype_common( + x, + null, + ptype, + x_arg, + error_call + ) + + if (is.object(x)) { + # The list input type should not affect the transposition process in any + # way. In particular, supplying a list subclass that doesn't have a + # `vec_cast.subclass.list` method shouldn't prevent the insertion of + # `list(null)` before the transposition. The fact that we must insert + # `list(null)` should be considered an internal detail. + x <- unclass(x) + } + + if (!is_null(null)) { + # Always perform `null` checks + null <- vec_cast( + x = null, + to = ptype, + x_arg = "null", + to_arg = "", + call = error_call + ) + + vec_check_recyclable( + x = null, + size = size, + arg = "null", + call = error_call + ) + + if (vec_any_missing(x)) { + null <- list(null) + x <- vec_assign(x, vec_detect_missing(x), null) + } + } + + x_size <- vec_size(x) + sizes <- vec_rep(x_size, times = size) + + out <- list_interleave( + x, + size = size, + ptype = ptype, + name_spec = "inner", + x_arg = x_arg, + error_call = error_call + ) + + # Chop the one big vector into transposed pieces of size `x_size` + out <- vec_chop(out, sizes = sizes) + + out +} + +# Same as `ptype_finalize()` in `vec_recode_values()` and `vec_if_else()` +list_transpose_ptype_common <- function( + x, + null, + ptype, + x_arg, + error_call +) { + if (!is_null(ptype)) { + # Validate and return user specified `ptype` + ptype <- vec_ptype(ptype, x_arg = "ptype", call = error_call) + return(vec_ptype_finalise(ptype)) + } + + # Compute from `x` + ptype <- vec_ptype_common(!!!x, .arg = x_arg, .call = error_call) + + if (!is_null(null)) { + # Layer in `null` + ptype <- vec_ptype2( + x = null, + y = ptype, + x_arg = "null", + y_arg = "", + call = error_call + ) + } + + ptype +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 1206fdde4..1ffc21fcb 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -48,6 +48,7 @@ reference: - vec_c - list_combine - vec_interleave + - list_transpose - vec_cbind - vec_rbind - name_spec diff --git a/man/list_transpose.Rd b/man/list_transpose.Rd new file mode 100644 index 000000000..fada4d5bf --- /dev/null +++ b/man/list_transpose.Rd @@ -0,0 +1,200 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/list-transpose.R +\name{list_transpose} +\alias{list_transpose} +\title{Transpose a list of vectors} +\usage{ +list_transpose( + x, + ..., + null = NULL, + size = NULL, + ptype = NULL, + x_arg = caller_arg(x), + error_call = current_env() +) +} +\arguments{ +\item{x}{A list of vectors. +\itemize{ +\item Each vector will be \link[=theory-faq-recycling]{recycled} to the common size +before transposing. +\item Each vector will be \link[=theory-faq-coercion]{cast} to the common type before +transposing. +}} + +\item{...}{These dots are for future extensions and must be empty.} + +\item{null}{A value to replace \code{NULL} elements with before transposing. + +If left unspecified, any \code{NULL} elements in \code{x} result in an error. + +If specified: +\itemize{ +\item Will be \link[=theory-faq-recycling]{recycled} to the common size of \code{x} before +transposing. +\item Will participate in determining the common type, and will be +\link[=theory-faq-coercion]{cast} to that type before transposing. +} + +Note that \code{null} can alter the output type, but cannot alter the output +size. See the examples for consequences of this.} + +\item{size}{The expected size of each element of \code{x}. If not provided, +computed automatically by \code{\link[=vec_size_common]{vec_size_common()}}.} + +\item{ptype}{The expected type of each element of \code{x}. If not provided, +computed automatically by \code{\link[=vec_ptype_common]{vec_ptype_common()}}.} + +\item{x_arg}{Argument name used in error messages.} + +\item{error_call}{The execution environment of a currently +running function, e.g. \code{caller_env()}. The function will be +mentioned in error messages as the source of the error. See the +\code{call} argument of \code{\link[rlang:abort]{abort()}} for more information.} +} +\value{ +A list of vectors with the following invariants: + +For the list: +\itemize{ +\item \verb{vec_ptype(list_transpose(x)) == } +\item \code{vec_size(list_transpose(x)) == vec_size_common(!!!x)} +} + +For the list elements: +\itemize{ +\item \code{vec_ptype(list_transpose(x)[[i]]) == vec_ptype_common(!!!x)} +\item \code{vec_size(list_transpose(x)[[i]]) == vec_size(x)} +} +} +\description{ +\code{list_transpose()} takes a list of vectors, transposes it, and returns a new +list of vectors. + +To predict the output from \code{list_transpose()}, swap the size of the list +with the size of the list elements. For example: +\itemize{ +\item Input: List of size 2, elements of size 3 +\item Output: List of size 3, elements of size 2 +} +} +\examples{ +# I: List size 3, Element size 2 +# O: List size 2, Element size 3 +list_transpose(list(1:2, 3:4, 5:6)) + +# With data frames +x <- data_frame(a = 1:2, b = letters[1:2]) +y <- data_frame(a = 3:4, b = letters[3:4]) +list_transpose(list(x, y)) + +# Size 1 elements are recycled +list_transpose(list(1, 2:3, 4)) + +# --------------------------------------------------------------------------- +# Using `size` and `ptype` + +# With size 0 elements, the invariants are a bit tricky! +# This must return a size 0 list, but then you lose expected +# type (integer) and size (2) information about the elements. +# Losing that information makes it difficult to reverse the +# transposition. +# +# I: List size 2, Element size 0 +# O: List size 0, Element size 2 +x <- list(integer(), integer()) +out <- list_transpose(x) +out + +# Note how transposing a second time doesn't recover the original list +list_transpose(out) + +# To work around this, provide the lost `size` and `ptype` manually +list_transpose(out, size = vec_size(x), ptype = vec_ptype_common(!!!x)) + +# --------------------------------------------------------------------------- +# Padding + +# If you'd like to pad with a missing value rather than erroring, +# you might do something like this, which left-pads +x <- list(1, 2:5, 6:7) +try(list_transpose(x)) + +sizes <- list_sizes(x) +size <- max(sizes) +index <- which(sizes != size) + +x[index] <- lapply( + index, + function(i) vec_c(rep(NA, times = size - sizes[[i]]), x[[i]]) +) +x + +list_transpose(x) + +# --------------------------------------------------------------------------- +# `NULL` handling + +# `NULL` values aren't allowed in `list_transpose()` +x <- list(1:3, NULL, 5:7, NULL) +try(list_transpose(x)) + +# Replace them with `null` +list_transpose(x, null = NA) +list_transpose(x, null = -(1:3)) + +# When you don't know the list element size up front, but you still want +# to replace `NULL`s with something, use a size 1 `null` value which will +# get recycled to the element size after it has been computed +list_transpose(list(), null = NA) +list_transpose(list(1, NULL, 3), null = NA) +list_transpose(list(1, NULL, 3:4), null = NA) + +# When you do know the list element size up front, it's best to also provide +# that information as `size`, as this helps direct the recycling process +# for `null`, particularly in the cases of an empty list, a list of `NULL`s, +# or a list of size 1 elements. You typically know the list element size if +# you are providing a `null` of size != 1, because otherwise you wouldn't +# have been able to make `null` in the first place! +size <- 2L +null <- 3:4 + +# `size` overrides the inferred element size of 0 +# +# I: List size 0, Element size 0 +# O: List size 0, Element size 0 +try(list_transpose(list(), null = null)) +# I: List size 0, Element size 2 +# O: List size 2, Element size 0 +list_transpose(list(), null = null, size = size) + +# Same idea here +# +# I: List size 1, Element size 0 +# O: List size 0, Element size 1 +try(list_transpose(list(NULL), null = null)) +# I: List size 1, Element size 2 +# O: List size 2, Element size 1 +list_transpose(list(NULL), null = null, size = size) + +# `size` overrides the inferred element size of 1 +# +# I: List size 2, Element size 1 +# O: List size 1, Element size 2 +try(list_transpose(list(1, 2), null = null)) +# I: List size 2, Element size 2 +# O: List size 2, Element size 2 +list_transpose(list(1, 2), null = null, size = size) + +# The reason that `list_transpose()` recycles `null` to the common size +# rather than letting `null` participate in common size determination is +# due to this example. When supplying a size 1 `null`, most of the time +# you don't know the element size, and you just want `null` to recycle to +# whatever the required size will be. If `null` participated in determining +# the common size, the output of this would be `list(logical())` rather than +# `list()` because the element size would be computed as 1. Since a size 1 +# `null` is much more common than a size !=1 `null`, we've optimized for this +# case at the cost of needing to specify `size` explicitly in some scenarios. +list_transpose(list(), null = NA) +} diff --git a/tests/testthat/_snaps/list-transpose.md b/tests/testthat/_snaps/list-transpose.md new file mode 100644 index 000000000..780367c37 --- /dev/null +++ b/tests/testthat/_snaps/list-transpose.md @@ -0,0 +1,197 @@ +# `x` must be a list + + Code + list_transpose(1) + Condition + Error in `list_transpose()`: + ! `1` must be a list, not the number 1. + +--- + + Code + list_transpose(1, x_arg = "x", error_call = quote(foo())) + Condition + Error in `foo()`: + ! `x` must be a list, not the number 1. + +# `...` must be empty + + Code + list_transpose(1, 2) + Condition + Error in `list_transpose()`: + ! `...` must be empty. + x Problematic argument: + * ..1 = 2 + i Did you forget to name an argument? + +# recycles inputs to common size before transposing + + Code + x <- list(1:2, 3:5) + list_transpose(x) + Condition + Error in `list_transpose()`: + ! Can't recycle `x[[1]]` (size 2) to match `x[[2]]` (size 3). + +# respects `size` + + Code + list_transpose(list(1:2), size = 3) + Condition + Error in `list_transpose()`: + ! Can't recycle `list(1:2)[[1]]` (size 2) to size 3. + +# respects `ptype` + + Code + list_transpose(list(1, 2), ptype = character()) + Condition + Error in `list_transpose()`: + ! Can't convert `list(1, 2)[[1]]` to . + +--- + + Code + list_transpose(list(1, 2), ptype = character(), x_arg = "x", error_call = quote( + foo())) + Condition + Error in `foo()`: + ! Can't convert `x[[1]]` to . + +# doesn't allow `NULL` elements + + Code + list_transpose(list(1:4, NULL, 5:8)) + Condition + Error in `list_transpose()`: + ! `list(1:4, NULL, 5:8)[[2]]` must be a vector, not `NULL`. + +# doesn't allow scalar elements + + Code + list_transpose(list(1:4, lm(1 ~ 1))) + Condition + Error in `list_transpose()`: + ! `list(1:4, lm(1 ~ 1))[[2]]` must be a vector, not a object. + +--- + + Code + list_transpose(list(1:4, lm(1 ~ 1)), x_arg = "x", error_call = quote(foo())) + Condition + Error in `foo()`: + ! `x[[2]]` must be a vector, not a object. + +# `x` being a list subclass can't affect the transposition + + Code + vec_cast(list(null), to = x) + Condition + Error: + ! Can't convert `list(null)` to . + +# `x` being a doesn't affect the transposition + + Code + list_transpose(x) + Condition + Error in `list_transpose()`: + ! `x[[1]]` must be a vector, not `NULL`. + +# `null` must be a vector + + Code + list_transpose(x, null = lm(1 ~ 1)) + Condition + Error in `list_transpose()`: + ! `null` must be a vector, not a object. + +--- + + Code + list_transpose(x, null = lm(1 ~ 1)) + Condition + Error in `list_transpose()`: + ! `null` must be a vector, not a object. + +# `null` participates in common type determination + + Code + list_transpose(x, null = "x") + Condition + Error in `list_transpose()`: + ! Can't combine `null` and . + +--- + + Code + list_transpose(x, null = "x", ptype = double()) + Condition + Error in `list_transpose()`: + ! Can't convert `null` to . + +--- + + Code + list_transpose(x, null = "x") + Condition + Error in `list_transpose()`: + ! Can't combine `null` and . + +--- + + Code + list_transpose(x, null = "x", ptype = double()) + Condition + Error in `list_transpose()`: + ! Can't convert `null` to . + +# `null` size 0 behavior + + Code + list_transpose(list(1, 2), null = double()) + Condition + Error in `list_transpose()`: + ! Can't recycle `null` (size 0) to size 1. + +--- + + Code + list_transpose(list(1, 2, NULL), null = double()) + Condition + Error in `list_transpose()`: + ! Can't recycle `null` (size 0) to size 1. + +# `null` size >1 behavior + + Code + list_transpose(list(), null = 3:4) + Condition + Error in `list_transpose()`: + ! Can't recycle `null` (size 2) to size 0. + +--- + + Code + list_transpose(list(NULL), null = 3:4) + Condition + Error in `list_transpose()`: + ! Can't recycle `null` (size 2) to size 0. + +--- + + Code + list_transpose(list(1, 2), null = 3:4) + Condition + Error in `list_transpose()`: + ! Can't recycle `null` (size 2) to size 1. + +--- + + Code + list_transpose(list(1, 2, NULL), null = 3:4) + Condition + Error in `list_transpose()`: + ! Can't recycle `null` (size 2) to size 1. + diff --git a/tests/testthat/test-list-transpose.R b/tests/testthat/test-list-transpose.R new file mode 100644 index 000000000..8ff1d908c --- /dev/null +++ b/tests/testthat/test-list-transpose.R @@ -0,0 +1,550 @@ +test_that("transposes vectors", { + expect_identical( + list_transpose(list(1:2, 3:4, 5:6)), + list(c(1L, 3L, 5L), c(2L, 4L, 6L)) + ) +}) + +test_that("transposes data frames", { + expect_identical( + list_transpose(list( + data_frame(a = 1:3, b = letters[1:3]), + data_frame(a = 4:6, b = letters[4:6]) + )), + list( + data_frame(a = c(1L, 4L), b = letters[c(1L, 4L)]), + data_frame(a = c(2L, 5L), b = letters[c(2L, 5L)]), + data_frame(a = c(3L, 6L), b = letters[c(3L, 6L)]) + ) + ) +}) + +test_that("works with empty `x`", { + # Input: + # - List size 0 + # - Element size 0 (inferred) + expect_identical(list_transpose(list()), list()) + + # Input + # - List size 0 + # - Element size 2 (provided) + # - Element type unspecified (inferred) + # Output + # - List size 2 + # - Element size 0 + # - Element type unspecified + expect_identical( + list_transpose(list(), size = 2), + list(unspecified(), unspecified()) + ) + + # Input + # - List size 0 + # - Element size 2 (provided) + # - Element type integer (provided) + # Output + # - List size 2 + # - Element size 0 + # - Element type integer + expect_identical( + list_transpose(list(), size = 2, ptype = integer()), + list(integer(), integer()) + ) +}) + +test_that("can recover original type and size with manual `ptype` and `size`", { + # - List size 2 + # - Element size 0 + # - Element type integer + x <- list(integer(), integer()) + + # - List size 0 + # - Element size 2 (but no elements) + # - Element type integer (but no elements) + out <- list_transpose(x) + expect_identical(out, list()) + + # Simply transposing again doesn't recover the original `x`, but supplying + # a known `ptype` and `size` does + expect_identical( + list_transpose(out, size = vec_size(x), ptype = vec_ptype_common(!!!x)), + x + ) +}) + +test_that("retains only inner names", { + # I don't think we should expose `name_spec`, we've hard coded it to `"inner"` + # for now. What would this even do with outer names? Exposing `name_spec` for + # the interleave step would allow making names of `a_w` and `b_y` via a glue + # spec, which feels weird and not useful. + x <- list(a = c(w = 1, x = 2), b = c(y = 3, z = 4)) + + expect_identical( + list_transpose(x), + list( + c(w = 1, y = 3), + c(x = 2, z = 4) + ) + ) + + # Silent repair of duplicate data frame row names + x <- list( + data.frame(a = 1, row.names = "x"), + data.frame(a = 2, row.names = "x") + ) + + expect_silent({ + expect_identical( + list_transpose(x), + list(data.frame(a = c(1, 2), row.names = c("x...1", "x...2"))) + ) + }) +}) + +test_that("`x` must be a list", { + expect_snapshot(error = TRUE, { + list_transpose(1) + }) + expect_snapshot(error = TRUE, { + list_transpose(1, x_arg = "x", error_call = quote(foo())) + }) +}) + +test_that("`...` must be empty", { + expect_snapshot(error = TRUE, { + list_transpose(1, 2) + }) +}) + +test_that("recycles inputs to common size before transposing", { + expect_identical( + list_transpose(list(1, 2:3, 4)), + list(c(1, 2, 4), c(1, 3, 4)) + ) + expect_snapshot(error = TRUE, { + x <- list(1:2, 3:5) + list_transpose(x) + }) +}) + +test_that("respects `size`", { + # Useful for the case where you somehow know the element size from somewhere + # else, but you also happen to only have all size 1 elements right now + expect_identical( + list_transpose(list(1L, 2L), size = 3), + list(1:2, 1:2, 1:2) + ) + + expect_snapshot(error = TRUE, { + list_transpose(list(1:2), size = 3) + }) +}) + +test_that("respects `ptype`", { + expect_identical( + list_transpose(list(1, 2), ptype = integer()), + list(1:2) + ) + + expect_snapshot(error = TRUE, { + list_transpose( + list(1, 2), + ptype = character() + ) + }) + expect_snapshot(error = TRUE, { + list_transpose( + list(1, 2), + ptype = character(), + x_arg = "x", + error_call = quote(foo()) + ) + }) +}) + +test_that("doesn't allow `NULL` elements", { + # These would break the invariants around the size of the output relative + # to the size of the input if we just dropped them + expect_snapshot(error = TRUE, { + list_transpose(list(1:4, NULL, 5:8)) + }) +}) + +test_that("doesn't allow scalar elements", { + expect_snapshot(error = TRUE, { + list_transpose(list(1:4, lm(1 ~ 1))) + }) + expect_snapshot(error = TRUE, { + list_transpose(list(1:4, lm(1 ~ 1)), x_arg = "x", error_call = quote(foo())) + }) +}) + +test_that("`x` being a list subclass can't affect the transposition", { + x <- structure(list(1, NULL, 2), class = c("my_list", "list")) + + null <- 0 + + # Note how this is an error. We perform a cast like this internally. + expect_snapshot(error = TRUE, { + vec_cast(list(null), to = x) + }) + + # But we unclass `x` first, so it won't matter. + # Our output type is always `` and as long as `obj_is_list()` + # passes, we don't care about the input type. + expect_identical( + list_transpose(x, null = null), + list(c(1, 0, 2)) + ) +}) + +test_that("`x` being a doesn't affect the transposition", { + # As a primitive function, `list_transpose()` doesn't know anything + # about ``, and shouldn't treat it specially + + # No preservation of type + x <- list_of(.ptype = integer()) + expect_identical(list_transpose(x), list()) + expect_identical(list_transpose(x, ptype = character()), list()) + + x <- list_of(NULL, .ptype = integer()) + expect_snapshot(error = TRUE, { + list_transpose(x) + }) + expect_identical( + list_transpose(x, null = "x"), + list() + ) + expect_identical( + list_transpose(x, null = "x", size = 2), + list("x", "x") + ) + + # `ptype` overrules list-of type + x <- list_of(1L, 2L) + expect_identical( + list_transpose(x, ptype = double()), + list(c(1, 2)) + ) + + # Common type determination with `null` overrules list-of type + x <- list_of(1L, NULL, 2L) + expect_identical( + list_transpose(x, null = 0), + list(c(1, 0, 2)) + ) +}) + +test_that("`null` replaces `NULL` elements", { + x <- list(1:2, NULL, 3:4, NULL) + + expect_identical( + list_transpose(x, null = 0L), + list( + int(1, 0, 3, 0), + int(2, 0, 4, 0) + ) + ) +}) + +test_that("`null` must be a vector", { + x <- list(1, NULL) + expect_snapshot(error = TRUE, { + list_transpose(x, null = lm(1 ~ 1)) + }) + + # Even when not used + x <- list(1, 2) + expect_snapshot(error = TRUE, { + list_transpose(x, null = lm(1 ~ 1)) + }) +}) + +test_that("`null` participates in common type determination", { + x <- list(1L, NULL) + expect_identical( + list_transpose(x, null = 0), + list(c(1, 0)) + ) + expect_snapshot(error = TRUE, { + list_transpose(x, null = "x") + }) + expect_snapshot(error = TRUE, { + list_transpose(x, null = "x", ptype = double()) + }) + + # Even when not used + x <- list(1L, 2L) + expect_identical( + list_transpose(x, null = 0), + list(c(1, 2)) + ) + expect_snapshot(error = TRUE, { + list_transpose(x, null = "x") + }) + expect_snapshot(error = TRUE, { + list_transpose(x, null = "x", ptype = double()) + }) +}) + +test_that("`null` is recycled to common size of inputs or `size`", { + x <- list(1:2, NULL, 5:6) + expect_identical( + list_transpose(x, null = NA), + list(c(1L, NA, 5L), c(2L, NA, 6L)) + ) + + x <- list(1:2, NULL, 5:6) + expect_identical( + list_transpose(x, null = 3:4), + list(c(1L, 3L, 5L), c(2L, 4L, 6L)) + ) +}) + +test_that("`null` size 0 behavior", { + # Element common size is inferred to be 0 from `x` + # + # I: List size 0, Element size 0 + # O: List size 0, Element size 0 + expect_identical( + list_transpose(list(), null = double()), + list() + ) + # I: List size 1, Element size 0 + # O: List size 0, Element size 1 + expect_identical( + list_transpose(list(NULL), null = double()), + list() + ) + + # Element common size is inferred to be 1 from `x` + # + # I: List size 2, Element size 1 (can't recycle `null` to this) + # O: List size 1, Element size 2 + expect_snapshot(error = TRUE, { + list_transpose(list(1, 2), null = double()) + }) + # I: List size 3, Element size 1 (can't recycle `null` to this) + # O: List size 1, Element size 3 + expect_snapshot(error = TRUE, { + list_transpose(list(1, 2, NULL), null = double()) + }) + + # Like with the `null` size >1 case, if you are programming with + # `list_transpose()` and built `null` to be size 0, you obviously expect each + # element to also be size 0. So to guard against the all size 1 element case + # (when they should be recycled to a known size 0), supply the known element + # size. + size <- 0L + null <- double() + + # I: List size 2, Element size 0 + # O: List size 0, Element size 2 + expect_identical( + list_transpose(list(1, 2), null = null, size = size), + list() + ) +}) + +test_that("`null` size 1 behavior", { + # This example of `list_transpose(list(), null = 3)` is a big reason why + # we recycle `null` rather than letting it participate in common size + # determination. A size 1 `null` is a very common way to say "I don't know + # what the element size is, but replace `NULL` with this and recycle it.". + # Since the user has no preexisting knowledge about the element size, the + # size of `null` should not impact the output, and you should get `list()`, + # not `list(numeric())`. + + # Element common size is inferred to be 0 from `x` + # + # I: List size 0, Element size 0 (can recycle `null` to this) + # O: List size 0, Element size 0 + expect_identical( + list_transpose(list(), null = 3), + list() + ) + # I: List size 1, Element size 0 (can recycle `null` to this) + # O: List size 0, Element size 1 + expect_identical( + list_transpose(list(NULL), null = 3), + list() + ) + + # Element common size is inferred to be 1 from `x` + # + # I: List size 2, Element size 1 + # O: List size 1, Element size 2 + expect_identical( + list_transpose(list(1, 2), null = 3), + list(c(1, 2)) + ) + # I: List size 3, Element size 1 + # O: List size 1, Element size 3 + expect_identical( + list_transpose(list(1, 2, NULL), null = 3), + list(c(1, 2, 3)) + ) + + # Like with `null` size 0 and size >1, you can still supply `size` to override + # the inferred size if you know the element size is actually 1 + size <- 1L + null <- 3 + + # I: List size 0, Element size 1 + # O: List size 1, Element size 0 + expect_identical( + list_transpose(list(), null = null, size = size), + list(numeric()) + ) + # I: List size 1, Element size 1 + # O: List size 1, Element size 1 + expect_identical( + list_transpose(list(NULL), null = null, size = size), + list(3) + ) +}) + +test_that("`null` size >1 behavior", { + # Element common size is inferred to be 0 from `x` + # + # I: List size 0, Element size 0 (can't recycle `null` to this) + # O: List size 0, Element size 0 + expect_snapshot(error = TRUE, { + list_transpose(list(), null = 3:4) + }) + # I: List size 1, Element size 0 (can't recycle `null` to this) + # O: List size 0, Element size 1 + expect_snapshot(error = TRUE, { + list_transpose(list(NULL), null = 3:4) + }) + + # Element common size is inferred to be 1 from `x` + # + # I: List size 2, Element size 1 (can't recycle `null` to this) + # O: List size 1, Element size 2 + expect_snapshot(error = TRUE, { + list_transpose(list(1, 2), null = 3:4) + }) + # I: List size 3, Element size 1 (can't recycle `null` to this) + # O: List size 1, Element size 3 + expect_snapshot(error = TRUE, { + list_transpose(list(1, 2, NULL), null = 3:4) + }) + + # The idea is that if you are programming with `list_transpose()` and you are + # supplying a length >1 `null`, then you obviously know the expected element + # size, otherwise you wouldn't have been able to make `null`. So the correct + # way to generically program with `list_transpose()` and `null` and guard + # against both the empty list case and the all size 1 element case is to go + # ahead and supply that known element size. + size <- 2L + null <- c(3, 4) + + # I: List size 0, Element size 2 + # O: List size 2, Element size 0 + expect_identical( + list_transpose(list(), null = null, size = size), + list(double(), double()) + ) + # I: List size 1, Element size 2 + # O: List size 2, Element size 1 + expect_identical( + list_transpose(list(NULL), null = null, size = size), + list(3, 4) + ) + + # I: List size 2, Element size 2 + # O: List size 2, Element size 2 + expect_identical( + list_transpose(list(1, 2), null = null, size = size), + list(c(1, 2), c(1, 2)) + ) + # I: List size 3, Element size 2 + # O: List size 2, Element size 3 + expect_identical( + list_transpose(list(1, 2, NULL), null = null, size = size), + list(c(1, 2, 3), c(1, 2, 4)) + ) +}) + +test_that("`null` influences type in the empty `list()` case", { + # Input + # - List size 0 + # - Element size 0 (inferred from list) + # - Element type integer (inferred from `null`) + # Output + # - List size 0 + # - Element size 0 + # - Element type integer + expect_identical( + list_transpose(list(), null = 1L), + list() + ) + + # Input + # - List size 0 + # - Element size 0 (supplied by `size`) + # - Element type integer (inferred from `null`) + # Output + # - List size 0 + # - Element size 0 + # - Element type integer + expect_identical( + list_transpose(list(), null = 1L, size = 0), + list() + ) + + # Input + # - List size 0 + # - Element size 1 (supplied by `size`) + # - Element type integer (inferred from `null`) + # Output + # - List size 1 + # - Element size 0 + # - Element type integer + expect_identical( + list_transpose(list(), null = 1L, size = 1), + list(integer()) + ) + + # Input + # - List size 0 + # - Element size 2 (supplied by `size`) + # - Element type integer (inferred from `null`) + # Output + # - List size 2 + # - Element size 0 + # - Element type integer + expect_identical( + list_transpose(list(), null = 1L, size = 2), + list(integer(), integer()) + ) +}) + +test_that("`null` influences type in the only `NULL` case", { + # Input + # - List size 2 + # - Element size 1 (inferred from `NULL` being treated as size 1) + # - Element type integer (inferred from `null`) + # Output + # - List size 1 + # - Element size 2 + # - Element type integer + expect_identical( + list_transpose(list(NULL, NULL), null = 1L, size = 1L), + list(c(1L, 1L)) + ) + expect_identical( + list_transpose(list(NULL, NULL), null = 1L, size = 1L, ptype = double()), + list(c(1, 1)) + ) +}) + +test_that("`ptype` is finalized", { + # `vec_ptype(NA)` alone returns `unspecified()`, must also call + # `vec_ptype_finalize()` + expect_identical( + list_transpose(list(TRUE, FALSE), ptype = NA), + list(c(TRUE, FALSE)) + ) +})