Unpacking Assignment

2025-05-22

Getting Started

The zeallot package defines an operator for unpacking assignment, sometimes called parallel assignment or destructuring assignment in other programming languages. The operator is written as %<-% and used like this.

c(lat, lng) %<-% list(38.061944, -122.643889)

The result is that the list is unpacked into its elements, and the elements are assigned to lat and lng.

lat
#> [1] 38.06194
lng
#> [1] -122.6439

You can also unpack the elements of a vector.

c(lat, lng) %<-% c(38.061944, -122.643889)
lat
#> [1] 38.06194
lng
#> [1] -122.6439

You can unpack much longer structures, too, of course, such as the 6-part summary of a vector.

c(min_wt, q1_wt, med_wt, mean_wt, q3_wt, max_wt) %<-% summary(mtcars$wt)
min_wt
#> [1] 1.513
q1_wt
#> [1] 2.58125
med_wt
#> [1] 3.325
mean_wt
#> [1] 3.21725
q3_wt
#> [1] 3.61
max_wt
#> [1] 5.424

If the left-hand side and right-hand sides do not match, an error is raised. This guards against missing or unexpected values.

c(stg1, stg2, stg3) %<-% list("Moe", "Donald")
#> Error in c(stg1, stg2, stg3) %<-% list("Moe", "Donald"): missing value for variable `stg3`
c(stg1, stg2, stg3) %<-% list("Moe", "Larry", "Curley", "Donald")

Unpacking a returned value

A common use-case is when a function returns a list of values and you want to extract the individual values. In this example, the list of values returned by coords_list() is unpacked into the variables lat and lng.

#
# A function which returns a list of 2 numeric values.
# 
coords_list <- function() {
  list(38.061944, -122.643889)
}

c(lat, lng) %<-% coords_list()
lat
#> [1] 38.06194
lng
#> [1] -122.6439

In this next example, we call a function that returns a vector.

#
# Convert cartesian coordinates to polar
#
to_polar = function(x, y) {
  c(sqrt(x^2 + y^2), atan(y / x))
}

c(radius, angle) %<-% to_polar(12, 5)
radius
#> [1] 13
angle
#> [1] 0.3947911

Example: Intercept and slope of regression

You can directly unpack the coefficients of a simple linear regression into the intercept and slope.

c(inter, slope) %<-% coef(lm(mpg ~ cyl, data = mtcars))
inter
#> [1] 37.88458
slope
#> [1] -2.87579

Example: Unpacking the result of safely

The purrr package includes the safely function. It wraps a given function to create a new, “safe” version of the original function.

safe_log <- purrr::safely(log)

The safe version returns a list of two items. The first item is the result of calling the original function, assuming no error occurred; or NULL if an error did occur. The second item is the error, if an error occurred; or NULL if no error occurred. Whether or not the original function would have thrown an error, the safe version will never throw an error.

pair <- safe_log(10)
pair$result
#> [1] 2.302585
pair$error
#> NULL
pair <- safe_log("donald")
pair$result
#> NULL
pair$error
#> <simpleError in .Primitive("log")(x, base): non-numeric argument to mathematical function>

You can tighten and clarify calls to the safe function by using %<-%.

c(res, err) %<-% safe_log(10)
res
#> [1] 2.302585
err
#> NULL

Unpacking a data frame

A data frame is simply a list of columns, so the zeallot assignment does what you expect. It unpacks the data frame into individual columns.

c(mpg=, cyl=, dist=, hp=) %<-% mtcars

cyl
#>  [1] 6 6 4 6 8 6 8 4 4 6 6 8 8 8 8 8 8 4 4 4 4 8 8 8 8 4 4 4 8 6 8 4

Example: List of data frames

Bear in mind, a list of data frames is still just a list. The assignment will extract the list elements (which are data frames) but not unpack the data frames themselves.

c(gear3, gear4, gear5) %<-% split(mtcars, ~ gear)

head(gear3)
#>                    mpg cyl  disp  hp drat    wt  qsec vs am gear carb
#> Hornet 4 Drive    21.4   6 258.0 110 3.08 3.215 19.44  1  0    3    1
#> Hornet Sportabout 18.7   8 360.0 175 3.15 3.440 17.02  0  0    3    2
#> Valiant           18.1   6 225.0 105 2.76 3.460 20.22  1  0    3    1
#> Duster 360        14.3   8 360.0 245 3.21 3.570 15.84  0  0    3    4
#> Merc 450SE        16.4   8 275.8 180 3.07 4.070 17.40  0  0    3    3
#> Merc 450SL        17.3   8 275.8 180 3.07 3.730 17.60  0  0    3    3

head(gear4)
#>                mpg cyl  disp  hp drat    wt  qsec vs am gear carb
#> Mazda RX4     21.0   6 160.0 110 3.90 2.620 16.46  0  1    4    4
#> Mazda RX4 Wag 21.0   6 160.0 110 3.90 2.875 17.02  0  1    4    4
#> Datsun 710    22.8   4 108.0  93 3.85 2.320 18.61  1  1    4    1
#> Merc 240D     24.4   4 146.7  62 3.69 3.190 20.00  1  0    4    2
#> Merc 230      22.8   4 140.8  95 3.92 3.150 22.90  1  0    4    2
#> Merc 280      19.2   6 167.6 123 3.92 3.440 18.30  1  0    4    4

gear5
#>                 mpg cyl  disp  hp drat    wt qsec vs am gear carb
#> Porsche 914-2  26.0   4 120.3  91 4.43 2.140 16.7  0  1    5    2
#> Lotus Europa   30.4   4  95.1 113 3.77 1.513 16.9  1  1    5    2
#> Ford Pantera L 15.8   8 351.0 264 4.22 3.170 14.5  0  1    5    4
#> Ferrari Dino   19.7   6 145.0 175 3.62 2.770 15.5  0  1    5    6
#> Maserati Bora  15.0   8 301.0 335 3.54 3.570 14.6  0  1    5    8

The %<-% operator assigned four data frames to four variables, leaving the data frames intact.

Custom classes

zeallot includes implementations of destructure for data frames and linear model summaries. However, because destructure is a generic function, you can define new implementations for custom classes. When defining a new implementation keep in mind the return value must be a list so the values are properly unpacked.

Trailing values: the “everything else” clause

In some cases, you want the first few elements of a list or vector but do not care about the trailing elements. The summary.lm function, for example, returns a list of 11 values, and you may want only the first few. Fortunately, there is a way to capture those first few and say “don’t worry about everything else”.

lm_mpg_cyl <- lm(mpg ~ cyl, data = mtcars)

c(lmc_call, lmc_terms, lmc_residuals, ..rest) %<-% summary(lm_mpg_cyl)

lmc_call
#> lm(formula = mpg ~ cyl, data = mtcars)

lmc_terms
#> mpg ~ cyl
#> attr(,"variables")
#> list(mpg, cyl)
#> attr(,"factors")
#>     cyl
#> mpg   0
#> cyl   1
#> attr(,"term.labels")
#> [1] "cyl"
#> attr(,"order")
#> [1] 1
#> attr(,"intercept")
#> [1] 1
#> attr(,"response")
#> [1] 1
#> attr(,".Environment")
#> <environment: R_GlobalEnv>
#> attr(,"predvars")
#> list(mpg, cyl)
#> attr(,"dataClasses")
#>       mpg       cyl 
#> "numeric" "numeric"

head(lmc_residuals)
#>         Mazda RX4     Mazda RX4 Wag        Datsun 710    Hornet 4 Drive 
#>         0.3701643         0.3701643        -3.5814159         0.7701643 
#> Hornet Sportabout           Valiant 
#>         3.8217446        -2.5298357

The collector variable rest captures everything else.

str(rest)
#> List of 15
#>  $              : num 37.9
#>  $              : num -2.88
#>  $              : num 2.07
#>  $              : num 0.322
#>  $              : num 18.3
#>  $              : num -8.92
#>  $              : num 8.37e-18
#>  $              : num 6.11e-10
#>  $ aliased      : Named logi [1:2] FALSE FALSE
#>   ..- attr(*, "names")= chr [1:2] "(Intercept)" "cyl"
#>  $ sigma        : num 3.21
#>  $ df           : int [1:3] 2 30 2
#>  $ r.squared    : num 0.726
#>  $ adj.r.squared: num 0.717
#>  $ fstatistic   : Named num [1:3] 79.6 1 30
#>   ..- attr(*, "names")= chr [1:3] "value" "numdf" "dendf"
#>  $ cov.unscaled : num [1:2, 1:2] 0.4185 -0.0626 -0.0626 0.0101
#>   ..- attr(*, "dimnames")=List of 2
#>   .. ..$ : chr [1:2] "(Intercept)" "cyl"
#>   .. ..$ : chr [1:2] "(Intercept)" "cyl"

Because ..rest is prefixed with .. a variable called rest is created for the trailing values of the list.

Leading values and middle values

In addition to collecting trailing values, you can also collect initial values and assign specific remaining values.

c(..skip, e, f) %<-% list(1, 2, 3, 4, 5)
skip
#> [[1]]
#> [1] 1
#> 
#> [[2]]
#> [1] 2
#> 
#> [[3]]
#> [1] 3
e
#> [1] 4
f
#> [1] 5

Or you can assign the first value, skip values, and then assign the last value.

c(begin, ..middle, end) %<-% list(1, 2, 3, 4, 5)
begin
#> [1] 1
middle
#> [[1]]
#> [1] 2
#> 
#> [[2]]
#> [1] 3
#> 
#> [[3]]
#> [1] 4
end
#> [1] 5

Skipped values: anonymous elements

You can skip one or more values using a period (.) instead of a variable name.

c(x, ., z) %<-% list(1, 2, 3)
x
#> [1] 1
z
#> [1] 3

You can skip many values with the anonymous collector (..).

c(begin, .., end) %<-% list("hello", "blah", list("blah"), "blah", "world!")
begin
#> [1] "hello"
end
#> [1] "world!"

You can mix skips and collectors together to selectively keep and discard elements.

c(begin, ., ..middle, end) %<-% list(1, 2, 3, 4, 5)
begin
#> [1] 1
middle
#> [[1]]
#> [1] 3
#> 
#> [[2]]
#> [1] 4
end
#> [1] 5

Default values: handle missing values

You can specify a default value for a left-hand side variable using =, similar to specifying the default value of a function argument. This comes in handy when the number of elements returned by a function cannot be guaranteed. tail() for example may return fewer elements than asked for.

nums <- c(1, 2)
c(x, y) %<-% tail(nums, 2)
x
#> [1] 1
y
#> [1] 2

However, if we tried to get the last 3 elements an error would be raised because tail(nums, 3) still returns only 2 values.

c(x, y, z) %<-% tail(nums, 3)
#> Error in c(x, y, z) %<-% tail(nums, 3): missing value for variable `z`

We can fix the problem by specifying a default value for z.

c(x, y, z = NULL) %<-% tail(nums, 3)
x
#> [1] 1
y
#> [1] 2
z
#> NULL

Swapping values

A handy trick is swapping values without the use of a temporary variable.

c(first, last) %<-% c("Ai", "Genly")
first
#> [1] "Ai"
last
#> [1] "Genly"

c(first, last) %<-% c(last, first)
first
#> [1] "Genly"
last
#> [1] "Ai"

or

cat <- "meow"
dog <- "bark"

c(cat, dog, fish) %<-% c(dog, cat, dog)
cat
#> [1] "bark"
dog
#> [1] "meow"
fish
#> [1] "bark"