Introduction

What is Alikeness?

alike is similar to all.equal from base R except it only compares object structure. As with all.equal, the first argument (target) must be matched by the second (current).

library(alike)
alike(integer(5), 1:5)      # different values, but same structure
alike(integer(5), 1:4)      # wrong size
alike(integer(26), letters) # same size, but different types

alike only compares structural elements that are defined in target (a.k.a. the template). This allows "wildcard" templates. For example, we consider length zero vectors to have undefined length so those match vectors of any length:

alike(integer(), 1:5)
alike(integer(), 1:4)
alike(integer(), letters)  # type is still defined and must match

Similarly, if a template does not specify an attribute, objects with any value for that attribute will match:

alike(list(), data.frame())  # a data frame is a list with a attributes
alike(data.frame(), list())  # but a list does not have the data.frame attributes

As an extension to the wildcard concept, we interpret partially specified core R attributes. Here we allow any three column integer matrix to match:

mx.tpl <- matrix(integer(), ncol=3)          # partially specified matrix
alike(mx.tpl, matrix(sample(1:12), nrow=4))  # any number of rows match
alike(mx.tpl, matrix(sample(1:12), nrow=3))  # but column count must match

or a data frame of arbitrary number of rows, but same column structure as iris:

iris.tpl <- iris[0, ]                        # no rows, but structure is defined
alike(iris.tpl, iris[1:10, ])                # any number of rows match
alike(iris.tpl, CO2)                         # but column structure must match

"alikeness" is complex to describe, but should be intuitive to grasp. We recommend you look at the examples in the documentation for alike to get a sense for alikeness. If you want to understand the specifics, read on.

Motivation

alike allows for template based validation of function arguments, and is used for that purpose by validate. S3 object templates represent structural requirements in a manner akin to prototypes in some OOP languages. Verifying the structural similarity of an S3 object as it enters our functions allows us to write simpler and more robust code. The S4 system does this and more, but S3 objects are still used extensively in R code.

An alternative to template based validation is to directly check the structure of objects (e.g. is.numeric(.) && length(.) == .). There are a few advantages to the template based approach:

Object Comparison

Overview

alike compares objects on type, length, and attributes. Recursive structures are compared element by element. Language objects and functions are compared specially because the concept of a value within those is more complex (e.g., is the + in x + y just a value?).

We will defer discussion of attribute comparison to the attributes section.

Length Comparison

Objects must be the same length to be alike, unless the template (target) is zero length, in which case the object may be any length. Environments are an exception: we only require that all the elements present in target be present in current. Also, note that calls to ( are ignored in language objects, which may affect length computation.

Type Comparison

Type comparison is done on type (i.e. the typeof) with some adjustments to better align comparisons to "percieved" types as opposed to internal storage types.

Numerics and Integers

We allow integer vectors to be considered numeric, and short integer-like numerics to be treated as integers:

alike(1L, 1)     # `1` is not technically integer, but we treat it as such
alike(1L, 1.1)   # 1.1 is not integer-like
alike(1.1, 1L)   # integers can match numerics

This feature is designed to simplify checks for integer-like numbers. The following two expressions are roughly equivalent:

stopifnot(length(x) == 1L && (is.integer(x) || is.numeric(x) && floor(x) == x))
stopifnot(alike(integer(1L), x))

Note that we only check numerics of length <= 100 for integerness to avoid full scans on large vectors. We expect that the primary source of these integer-like numerics is hand input vectors (e.g. c(1, 2, 3)), so hopefully this compromise is not too limiting.

Functions

Closures, builtins, and specials are all treated as a single type, even though internally they are stored as different types.

Recursive Objects

alike will recurse through lists (and by extension data frames), pairlists, expressions, and environments and will check pairwise alikeness between the corresponding elements of the target and current objects.

Environments have slightly different comparison rules in two respects:

NULL elements within templates in recursive objects are considered undefined and as such act like wildcards:

alike(list(NULL, NULL), list(1:10, letters))       # two NULLs match two length list
alike(list(NULL, NULL), list(1:10, letters, iris)) # but not three length list

Note that top level NULLs do not act as wildcards:

alike(NULL, 1:10)                   # NULL only matches NULL

Treating NULL inconsistenly depending on whether it is nested or not is a compromise designed to make alike a better fit for argument validation because arguments that are NULL by default are fairly common.

alike will check for self-referential loops in nested environments and prevent infinite recursion. If you somehow introduce a self-referential structure in a template without using environments then alike will get stuck in an infinite recursion loop.

Language Objects

Language objects are also compared recursively, but alikeness has a slightly different meaning for them:

alike(quote(sum(a, b)), quote(sum(x, y)))   # calls are consistent
alike(quote(sum(a, b)), quote(sum(x, x)))   # calls are inconsistent
alike(quote(mean(a, b)), quote(sum(x, y)))  # functions are different

Since variables can contain anything we do not require them to match directly across calls. In the examples above the second call fails because the template defines different variables for each argument, but the current object uses the same variable twice. The third call fails because the functions are different and as such the calls are fundamentally different.

If a function is defined in the calling frame, alike will match.call it prior to testing alikeness:

fun <- function(a, b, c) NULL
alike(quote(fun(p, q, p)), quote(fun(y, x, x)))
alike(quote(fun(p, q, p)), quote(fun(b=y, x, x)))  # `match.call` re-orders arguments

Constants match any constants, but keep in mind that expressions like 1:10 or c(1, 2, 3) are calls to : and c respectively, not constants in the context of language objects.

Formulas are treated like calls, except that constants must match:

alike(y ~ x ^ 2, a ~ b ^ 2)
alike(y ~ x ^ 2, a ~ b ^ 3)

NULL is a wild card in calls as well:

str(one.arg.tpl <- as.call(list(NULL, NULL)))
alike(one.arg.tpl, quote(log(10)))
alike(one.arg.tpl, quote(sd(runif(20))))
alike(one.arg.tpl, quote(log(10, 10)))

Calls to ( are ignored when comparing calls since parentheses are redundant in call trees because the tree structure encodes operation precedence independent of operator precedence.

We concede that the rules for "alikeness" of language objects are arbitrary, but hope the outcomes of those rules is generally intuitive. Unfortunately value and structure are somewhat intertwined for language objects so we must impose our own view of what is value and what is structure.

Functions

Functions are alike if the signature of the current function can reasonably be interpreted as a valid method for the target function:

alike(print, print.default)   # print can be the generic for print.default
alike(print.default, print)   # but not vice versa

A method of a generic must have all arguments present in the generic, with the same default values if those are defined. If the generic contains ... then the method may have additional arguments, but must also contain ....

S4 and R5 (RC Objects)

S4 and RC objects are considered alike if current inherits from class(target). Since these objects embed structural information in their definitions alike relies on class alone to estabilish alikeness.

Pointer Objects

Objects of the following types are actually references to specific memory locations:

These are typically attached as attributes to other objects that contain the information required to establish alikeness (e.g. data.table, byte-compiled functions), so we only check their type.

Attribute Comparison

Normal Attributes

Much of the structure of an object is determined by attributes. alike recursively compares object attributes and requires them to be alike, unless the attribute is a special attribute or an environment. Environments within attributes in the template must be matched by an environment, but nothing is checked about the environments to avoid expensive computations on objects that commonly include environments in their attributes (e.g. formulas); note this is different than the treatment of environments as actual objects.

Only attributes present in the template object are checked:

alike(structure(logical(1L), a=integer(3L)), structure(TRUE, a=1:3, b=letters))
alike(structure(TRUE, a=1:3, b=letters), structure(logical(1L), a=integer(3L)))

Attributes present in current but missing in target may be anything at all.

Special Attributes

Overview

The special attributes are names, row.names, dim, dimnames, class, tsp, and levels. These attributes are discussed in sections 2.2 and 2.3 of the R Language Definition, and have well defined and consistently applied semantics in R. Since the semantics of these attributes are well known, we are able to define "alikeness" for them in a more granular way than we can for arbitrary attributes.

row.names and names

If present in target, then must be matched exactly by the corresponding attribute in current, except that:

alike(setNames(integer(), character()), 1:3)
alike(setNames(integer(), character()), c(a=1, b=2, c=3))
alike(setNames(integer(3), c("", "", "Z")), c(a=1, b=2, c=3))
alike(setNames(integer(3), c("", "", "Z")), c(a=1, b=2, Z=3))

dim

dim attributes must be identical between target and current, except that if a value of the dim vector is zero in target then the corresponding value in current can be any value. This is how comparisons like the following succeed:

mx.tpl <- matrix(integer(), ncol=3)                # partially specified matrix
alike(mx.tpl, matrix(sample(1:12), nrow=4))
alike(mx.tpl, matrix(sample(1:12), nrow=3))        # wrong number of columns
str(mx.tpl)    # notice 0 for 1st dimension

dimnames

Must also be identical, except that if the target value of the dimnames list for a particular dimension is NULL, then the corresponding dimnames value in current may be anything. As with names, zero character dimname element elements match any name.

mx.tpl <- matrix(integer(), ncol=3, dimnames=list(row.id=NULL, c("R", "G", "")))
mx.cur <- matrix(sample(0:255, 12), ncol=3, dimnames=list(row.id=1:4, rgb=c("R", "G", "Blue")))
mx.cur2 <- matrix(sample(0:255, 12), ncol=3, dimnames=list(1:4, c("R", "G", "b")))

alike(mx.tpl, mx.cur)
alike(mx.tpl, mx.cur2)

Note that dimnames can have a names attribute. This names attributed is treated as described in row.names and names.

names(dimnames(mx.tpl))

class

S3 objects are considered alike if the current class inherits from the target class. Note that "inheritance" here is used in a stricter context than in the typical S3 application:

To illustrate:

tpl <- structure(NULL, class=c("a", "b", "c"))
cur <- structure(NULL, class=c("x", "a", "b", "c"))
cur2 <- structure(NULL, class=c("a", "b", "c", "x"))

alike(tpl, cur)
alike(tpl, cur2)

tsp

The tsp attribute of ts objects behaves similarly to the dim attribute. Any component (i.e. start, end, frequency) that is set to zero will act as a wild card. Other components must be identical. It is illegal to set tsp components to zero throught the standard R interface, but you may use abstract as a work-around.

levels

Levels are compared like row.names and names.

Normal Attributes that Happen To Have Special Names

If an object contains one of the special attributes, but the attribute value is inconsistent with the standard definition of the attribute, alike will silently treat that attribute as any other normal attribute.

Modifying Comparison Behavior

.alike is a sister function to alike with some additional options exposed through the settings argument. Among other things, you can modify how attributes and types are compared with the attr.mode and type.mode parameters. See the documentation for those parameters in ?alike.

Creating Templates

From The Ground Up

You can always create your own templates by manually building R structures:

int.scalar <- integer(1L)
int.mat.2.by.4 <- matrix(integer(), 2, 4)
df.chr.num.num <- structure(list(character(), numeric(), numeric()), class="data.frame")  # avoid having to specify column names

Abstracting Existing Structures

Alternatively, you can start with a known structure, and abstract away the instance-specific details. For example, suppose we are sending sample collectors out on the field to record information about iris flowers:

iris.tpl <- iris[0, ]
alike(iris.tpl, iris.sample.1)  # make sure they submit data correctly

Or equivalently:

iris.tpl <- abstract(iris)

abstract is an S3 generic defined by alike along with methods for common objects. abstract primarily sets the length of atomic vectors to zero:

abstract(list(c(a=1, b=2, c=3), letters))

and also abstracts the dim, dimnames, and tsp attributes if present. Other attributes are left untouched unless a specific abstract method exists for a particular object that also modifies attributes. One example of such a method is abstract.lm, and it does some minor tweaking to the base abstractions to allow us to match models produced by lm:

df.dummy <- data.frame(x=runif(3), y=runif(3), z=runif(3))
mdl.tpl <- abstract(lm(y ~ x + z, df.dummy))
alike(mdl.tpl, lm(Sepal.Length ~ Sepal.Width + Petal.Width, iris))  # TRUE, expecting bi-variate model
cat(alike(mdl.tpl, lm(Sepal.Length ~ Sepal.Width, iris)))           # `cat` here to make error message legible

The error message is telling us that at index "terms" (i.e. lm(Sepal.Length ~ Sepal.Width, iris)$terms) alike was expecting a call to + instead of a symbol (i.e Sepal.Width + <somevar> instead of Sepal.Width). The message could certainly be more eloquent, but with a little context it should provide enough information to figure out the problem.

Performance Considerations

Sample Timings

We have gone to great lengths to make alike fast so that it can be included in other functions without concerns for what overhead. Here are some benchmarks measured on a Intel(R) Core(TM) i7-3667U CPU @@ 2.00GHz MBA running OS X Mavericks and R 3.1.2.:

library(microbenchmark)
type_and_len <- function(a, b) typeof(a) == typeof(b) && length(a) == length(b)  # for reference
microbenchmark(
  identical(rivers, rivers),
  alike(rivers, rivers),
  type_and_len(rivers, rivers)
)

While alike is slower than identical, it is competitive and is actually faster than a bare bones R function that checks types and length. As objects grow more complex, identical will obviously pull ahead, though alike should be sufficiently fast for most applications:

microbenchmark(
  identical(mtcars, mtcars),
  alike(mtcars, mtcars)
)

In the above example, we are comparing the data frames, their attributes, and the 11 columns individually.

Keep in mind that the complexity of the alike comparison is driven by the complexity of the template, not the object we are checking, so we can always manage the expense of the alike evaluation.

Comparisons that succeed will be substantially faster than comparisons that fail as the construction of error messages is non-trivial and we have prioritized optimization in the success case.

Language object comparison is relatively slow. We intend to optimize this some day.

Templates with large numbers of attributes (e.g. > 25) may scale non-linearly. We intend to optimize this some day, though in our experience objects with that many attributes are rare (note having multiple objects each with a handful attributes nested in recursive structures is not a problem).

Large objects will be slower to evaluate. Let us revisit the lm example, though this time we compare our template to itself to ensure that the comparisons succeed for alike, all.equal, and identical:

mdl.tpl <- abstract(lm(y ~ x + z, data.frame(x=runif(3), y=runif(3), z=runif(3))))
mb <- microbenchmark(unit="us",
  alike(mdl.tpl, mdl.tpl),       # compare mdl.tpl to itself to ensure success in all three scenarios
  all.equal(mdl.tpl, mdl.tpl),   # for reference
  identical(mdl.tpl, mdl.tpl)
)
summary(mb)[1:4]  # note: in microseconds

Even with template as large as lm results (check str(mdl.tpl)) we can evaluate alike thousands of times before the overhead becomes noticeable.

Pre-defining Templates

Some fairly innocuous R expressions carry substantial overhead. Consider:

df.tpl <- data.frame(a=integer(), b=numeric())
df.cur <- data.frame(a=1:10, b=1:10 + .1)

mb <- microbenchmark(unit="us",
  alike(df.tpl, df.cur),
  alike(data.frame(integer(), numeric()), df.cur)
)
summary(mb)[1:4]  # note: in microseconds

data.frame is a particularly slow constructor, but in general you are best served by defining your templates (including calls to abstract) outside of your function so they are created on package load rather than every time your function is called.

Miscellaneous

alike as an S3 generic

alike is not currently an S3 generic, but will likely one in the future provided we can create an implementation with and acceptable performance profile.



brodieG/alike documentation built on May 13, 2019, 7:44 a.m.