Repository navigation
Naming conflict and unexpected behavior with the functional form of data.table DT() #5129
Description
Activity
Great find. I can reproduce the first error :
> D[1:3,] |> DT(D[4:5,], on="cyl") Error: object of type 'closure' is not subsettable > debugger() Message: Error: object of type 'closure' is not subsettable Available environments had calls: 1: DT(D[1:3, ], D[4:5, ], on = "cyl") 2: `[.data.table`(x, ...) 3: tryCatch(eval(.massagei(isub), x, ienv), error = function(e) { if (grepl(":=.*defined for use in j.*only", e$message)) stopf("Operator := detected in i, the first argument inside DT[.. 4: tryCatchList(expr, classes, parentenv, handlers) 5: tryCatchOne(expr, names, parentenv, handlers[[1]]) 6: value[[3]](cond) 7: .checkTypos(e, names_x) 8: stopf(err$message) 9: stop(gettextf(fmt, ..., domain = domain), domain = NA, call. = FALSE)Seems like that line is finding
stats::Dfunction rather than theDobject in calling scope. Hopefully that's just anenclos=to fix up. Or maybe the.massageidoesn't need to happen wheniisdata.frame.I think that the expectation when calling a data.table query on a data.frame is that it should behave in a similar way; that is,
assignments and modifications should affect the original data.frame.I'm glad you brought this up. I was thinking about that too. I'm not sure about expectations, but I fear the fear. Do we feel the fear and do it anyway, or do we be more cautious and fit into dplyr and R-style chains which copy-on-write?
Consider the following. It is currently possible in data.table-dev to modify
mtcarsby reference usingDT().# fresh R session > head(mtcars) mpg cyl disp hp drat wt qsec vs am gear carb Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4 4 Mazda RX4 Wag 21.0 6 160 110 3.90 2.875 17.02 0 1 4 4 Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4 1 Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0 3 1 Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0 3 2 Valiant 18.1 6 225 105 2.76 3.460 20.22 1 0 3 1 > find("mtcars") [1] "package:datasets" > require(data.table) Loading required package: data.table data.table 1.14.1 IN DEVELOPMENT built 2021-09-02 03:56:34 UTC; mdowle using 6 threads (see ?getDTthreads). Latest news: r-datatable.com > mtcars |> DT(2,cyl:=NA) 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 NA 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 [ snip data.frame print method output ] > head(mtcars) mpg cyl disp hp drat wt qsec vs am gear carb Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4 4 Mazda RX4 Wag 21.0 NA 160 110 3.90 2.875 17.02 0 1 4 4 Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4 1 Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0 3 1 Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0 3 2 Valiant 18.1 6 225 105 2.76 3.460 20.22 1 0 3 1 > find("mtcars") [1] "package:datasets" > head(datasets::mtcars) mpg cyl disp hp drat wt qsec vs am gear carb Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4 4 Mazda RX4 Wag 21.0 NA 160 110 3.90 2.875 17.02 0 1 4 4 Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4 1 Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0 3 1 Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0 3 2 Valiant 18.1 6 225 105 2.76 3.460 20.22 1 0 3 1
So it did actually change
mtcarsby reference indataset's namespace. I'm thinking that leaving that door open is asking for trouble. In complicated dependency chains, and packages in companies that accept all kinds of input from different users at different times (i.e. hard to monitor and track), we can't havedata.framebeing changed by reference in other places, inadvertently. The idea was that:=only works on data.table. That you have to opt-in to data.table to get modify-by-reference. You have to use a different operator (i.e.:=), and:=has to be used on a data.table. In other words, there were deliberate hurdles/protections in place to achieve modify-by-reference. If we allowDT(,:=)to modify a data.frame by reference, then a package can be created which usesDT(,:=)to do so. Now, if that package accepts data input from the user, and a non-data.table-aware user passes a data.frame to that package, their data.frame might be modified by reference by that package. That crosses a line where the user didn't opt-in to that. We can't just trust all packages using DT(,:=) on a data.frame to copy() the data.frame first ... it needs to be prevented for safety. WDYT?Reacted by Michael Young, Tim Taylor and Grant McDermottMy 2 cents:
it needs to be prevented for safety
Agree. I suppose the question is whether to disable
:=completely when passed a DF, or simply fall back to regular copy-on-modify assignment (maybe invokingcopy, maybe passing towithin).My strong preference would be for the latter, possibly with a one-time warning.
Reacted by Mark Fairbanks and Matt DowleAgree with Grant here -- backing up to do what was asked, but safely, seems like the user-friendliest choice. I would hope this is most likely to happen in use cases where a copy is not too expensive
Reacted by Matt Dowle1-
From @mattdowle's first commentOr maybe the
.massageidoesn't need to happen when i is data.frame.The issue does not happen only with a data.frame but also with a data.table.
2-
I thought that this issue (name conflict) was only related to the special case where the dataset is namedDbut I've just realized that it is much more general: When a dataset has the same name as a built-in function (at least those that I randomly chose), then this generates an error if theiargument starts with the dataset name (like inDT$...orDT[...]...etc.). The examples below explain what I mean in a better way.# all dataset names below conflict with built-in functions filter = choose = beta = mtcars dt = df = D = as.data.table(filter) df |> DT(df[, .I[which.max(mpg)], by=cyl]$V1) # error dt |> DT(dt[, .I[which.max(mpg)], by=cyl]$V1) # error D |> DT(D[, .I[which.max(mpg)], by=cyl]$V1) # error choose |> DT(choose[, .I[which.max(mpg)], by=cyl]$V1) # error filter |> DT(filter[, .I[which.max(mpg)], by=cyl]$V1) # error beta |> DT(beta[, .I[which.max(mpg)], by=cyl]$V1) # errorI think that the issue is caused by the line
.global$print = ""in the current implementation ofDTfunction. When this line is removed, then everything works fine.DT2 = function (x, ...) { old = getOption("datatable.optimize") if (!is.data.table(x) && old > 2L) { options(datatable.optimize = 2L) } ans = data.table:::`[.data.table`(x, ...) options(datatable.optimize = old) # data.table:::.global$print = "" # REMOVING .global$print = "" ans } df |> DT2(df[, .I[which.max(mpg)], by=cyl]$V1) # works dt |> DT2(dt[, .I[which.max(mpg)], by=cyl]$V1) # works D |> DT2(D[, .I[which.max(mpg)], by=cyl]$V1) # works choose |> DT2(choose[, .I[which.max(mpg)], by=cyl]$V1) # works filter |> DT2(filter[, .I[which.max(mpg)], by=cyl]$V1) # works beta |> DT2(beta[, .I[which.max(mpg)], by=cyl]$V1) # worksSide effect of removing
.global$print = "": assignment at the end of a chain (when piping) does not print anything (behave likeDT[, col:=value]) unless|> DT()is added. example:as.data.table(mtcars) |> DT(, halfmpg := mpg/2)does print anything whileas.data.table(mtcars) |> DT(, halfmpg := mpg/2) |> DT()does print the data. This is discussed in #5106.3-
About modification in place vs copy-on-modify
I understand the risk and agree that:=should not modify data.frame by reference (and so agree that copy-on-modify is better). But I wonder ifDTfunction could not gain a new argumentinplacethat defaults toFALSE. This would be useful in the special case where copying is expensive.Reacted by Matt DowleThanks a lot for this investigation. Saves me a lot of time investigating that naming conflict. I'll take a look.
Just to reply on
inplacein point 3 and the comments about copying being expensive. I've been working on makingcopy()be a shallow copy. Then it won't be expensive. There was talk of exportingshallow()but I always wanted to makecopy()be shallow and just havecopy(). That way, existing usage ofcopy()becomes faster too. After acopy(), the first:=on a column would need to copy that column. It's a bit tricky for multiple reasons but that's the direction I'm working on at the moment. Let's reconsiderinplaceaftercopy()is shallow. I can see thatinplacemight still be useful after that so long as it didn't open the door to allowing an inplace update on a data.frame that was referenced by other objects.Reacted by Kamgang-B and Mark Fairbanks@Kamgang-B PR #5176 now merged should solve issue 2 here. Thanks again for your investigation and tests. Still working on
copy()and that will take longer.
Issue 1: When working with a
data.frame, assignment to a new variable does affect the original dataset while assignment to an existing variable (modification/update) does.I think that the expectation when calling a data.table query on a data.frame is that it should behave in a similar way; that is,
assignments and modifications should affect the original data.frame. This is partially useful to avoid to reassign the data back every time we use DT on a data.frame. This will also make it consistent with what would happen when using a data.table and not a data.frame.
Issue 2: Naming a
data.frameordata.tableDleads to errors when used withDTfunction:Info session