I am learning about S3, and trying to understand behaviors that happen when objects lose their S3 class because they are assigned into a vector that is not of that class.
Essentially, this behavior, which is also what underlies ifelse() stripping classes and other attributes:
########## Test 1: Assignment from a vector with an S3 class to another vector loses the class ##########
make_classA <- function(vals) {
output <- structure(as.character(vals),
other_attrib=round(sqrt(abs(vals))), # Just for demonstration/testing
class='classA')
return(output)
}
# Make it so subsetting classA keeps its attributes
`[.classA` <- function(x, i, ...) {
out <- unclass(x)[i] # subset the underlying vector
structure(
out,
other_attrib = attr(x, "other_attrib")[i],
class = "classA"
)
}
s3dat <- make_classA(c(4,9,16,25))
# Subsetting keeps attributes
attributes(s3dat[2:3])
# Make a vector to assign into that's just R's basic numeric type
basic <- c(1.3,2.4,3.5,4.6)
# If I assign a value from a classA vector to a numeric vector, the numeric vector is converted to character (not classA) and class/attributes are lost
basic[2] <- s3dat[2]
class(basic)
# [1] "character"
attributes(basic)
# NULL
I understand that the above is an expected and normal (if unfortunate) aspect of how R works, and I thought it was unavoidable.
From what I thought I understood, if "basic" is a vector or data.frame column containing data as one of the basic R types, and "s3dat" is a vector or data.frame column containing data as an S3 class:
You can define assignment methods for your S3 class to control the behavior of
s3dat[2] <- basic[2]You cannot do anything about the reverse scenario,
basic[2] <- s3dat[2], that is entirely beyond your control. (But now I think I might be wrong about this...)
But then I noticed that POSIXlt (which is an S3 object) manages to produce different behaviors in the basic[2] <- s3dat[2] scenario I thought was beyond the class creator's control:
When the assignment is made between two vectors (Test 2 below), the vector the POSIXlt is assigned into is converted to a list, and the value "0" is assigned in the position where the POSIXlt would have been assigned.
When the assignment is made between two columns in a data.frame (Test 3 below), the POSIXlt is converted as if as.numeric() had been called on it.
########## Test 2: Assign an item from POSIXlt vector to a numeric vector ##########
library('sloop') # Provides the s3_dispatch() function
# Make a POSIXlt vector and a numeric vector
s3dat <- as.POSIXlt(c('2025-01-01 10:00:00','2025-01-02 10:00:00','2025-01-03 9:57:00'))
basic <- c(1.3,2.4,3.5)
# What methods would basic[2] <- s3dat[2] use?
s3_dispatch(basic[2] <- s3dat[2])
# <-.double
# <-.numeric
# <-.default
# What happens when I try it?
basic[2] <- s3dat[2]
# Warning message:
# In basic[2] <- s3dat[2] :
# number of items to replace is not a multiple of replacement length
# The result: basic has become a list, and basic[[2]] is 0
basic
# [[1]]
# [1] 1.3
#
# [[2]]
# [1] 0
#
# [[3]]
# [1] 3.5
########## Test 3: Assign a POSIXlt in a numeric column a data.frame ##########
library('sloop') # Provides the s3_dispatch() function
# Make a data.frame with a POSIXlt column and a numeric column
df <- data.frame(s3dat=as.POSIXlt(c('2025-01-01 10:00:00','2025-01-02 10:00:00','2025-01-03 9:57:00')),basic=c(1.3,2.4,3.5))
# What methods would df$basic[2] <- df$s3dat[2] use?
s3_dispatch(df$basic[2] <- df$s3dat[2])
# <-.double
# <-.numeric
# <-.default
# What happens when I try it?
df$basic[2] <- df$s3dat[2]
# No warning message
# The result: df$basic has remained numeric, and the value assigned was as.numeric(df$s3dat[2])
# s3dat basic
# 1 2025-01-01 10:00:00 1.30000e+00
# 2 2025-01-02 10:00:00 1.73583e+09
# 3 2025-01-03 09:57:00 3.50000e+00
as.numeric(df$s3dat[2])==df$basic[2] # TRUE
# Not shown for length: The behavior of df[2,'basic'] <- df[2,'s3dat'] is the same as in this test
How does POSIXlt do this?
I can kind of understand the behavior with vectors in Test 2, since the POSIXlt contains a list, so R converting the vector "basic" to a list might just be another case of the known behavior shown in Test 1. But what's going on with the data.frame in Test 3?
I explored the data.frame thing a little further, and found the difference seems to be whether the input column comes from a data.frame:
df$basic[2] <- s3dat[2]where the assigned value comes from a vector produces the same behavior as Test 2 a warning about length differences, and and df$basic[2] is turned into a list.basic[2] <- df$s3dat[2]where the assigned value comes from a data.frame column produces the same behavior as Test 3 - no warning, the POSIXlt is converted as if as.numeric() was called on it, and "basic" remains a numeric vector
What's happening to make the behaviors different with a data.frame?
And are there any other options for altering the behavior of basic[2] <- s3dat[2] or df$basic[2] <- db$s3dat[2] where "basic" contains data as one of the basic R types and "s3dat" contains data as an S3 class?