Some notes on equality, typing, and Sets

Adobe recently released sets, which was a feature we had been mulling over for BoxLang for a while. We re-started our internal discussions and rolled out our version of sets in the recent 1.14 release. However, we disagreed with what CF did in a few keys areas and I wanted to spell out our reasoning. In part, to show it was an on-purpose divergence, and also because perhaps they’ll be inclined to agree with us :slight_smile:

Set Theory

At its core, Set Theory is a branch of mathematics that represents a collection of items or values which are unique-- meaning one value doesn’t appear twice in the same set. So a set could be comprised of the numbers 1, 2, 3, 4, 5 but the numbers 1, 2, 3, 3 are not a set since 3 appears twice. It’s up to the set implementation to define how uniqueness is enforced, but in programming languages, the uniqueness of the set members is usually controlled by checking the members for equality with one another. Unfortunately, equality is where things can get fuzzy.

Typing and Equality

CFML is a loosely typed language. We don’t require a type to be set when declaring a variable, and our type system works in broad strokes-- “simple”, “string”, “numeric”, “boolean”. The actual behind-the-scenes types of the JVM objects used to represent your variables may vary based on how they are declared, and value are automatically coerced as-necessary by the engine. As such, each of the following values are “equal”:

true == "yes" == "true" == 1 == 1.0 == "1"

Values in CFML don’t have a specific inherent type so much as potential types they can be coerced into if necessary.

How CF’s Sets Work

So, if all those values above are “equal” (according to the == operator) then should only one of them be allowed to live in a CF set?

setNew( [ true, "yes", "true", 1, 1.0, "1" ] )

Well, it turns out CF’s set implementation says they are ALL DIFFERENT VALUES, and creates a set with 6 values, many of which are visually identical in the dump of the set.
image

CF sets also have another odd edge case behavior. 05 == 5 is true, but

writedump( setNew( [ 05,  5 ] ) )

counts those as separate values for some reason:
image

We feel this behavior is not idiomatic to how CF works, nor is it based on any documented principles of the CFML language! We have also declined to copy this behavior exactly.

Why?

So, what’s happening? Well, Adobe seemingly has just dropped in a java HashSet implementation and is allowing Java to define what equality means using Java’s rules-- which are nothing like CF’s rules for equality! And, unlike CF’s Sets, Java documents how equality is determined, and it’s via

obj1.equals( obj2 )

which validates the

  • specific java type (integer, vs float, vs string, vs boolean)
  • the hashcode of the two values

And it gets even worse. CFML is a case-insensitive language-- meaning the following two values are equal according to CFML’s == operator:

"brad" == "BRAD"

But what happens when we add them both to a CF set?

setNew( [ "brad", "BRAD" ] )

image

That’s right, we fall back to letting Java define equality, and the two values are listed as not equal, which is in violation of how the CFML languge works.

This is not CF sets, this is just Java sets in CF. Something we may as well just have used the Java classes directly for:

createObject( "java", "java.util.HashSet" ).init( [ "brad", "BRAD" ] )

Our Solution

In BoxLang, we wanted to do better. We wanted Box Sets to actually be sets that feel like they are a part of our language, not a Java set bolted onto the side. So we researched how other loosely typed languages handle sets. Part of language design is finding out what the precedent is from other languages that are more like your language. Ruby, JS, and PHP sets all use a strict equality check, or basically === for their sets. But the === operator in CFML has been a bit of a mess as well. Railo/Lucee implemented === a long time ago, but as an object reference comparison (like Java) which made little sense in CF. After Adobe CF added === as a typed equality check, Lucee changed to match.

In BoxLang, we decided to follow the same precedent of JS, Ruby, and PHP and use a === operator to determine equality for set membership. Using a stricter check helps eliminate problematic behaviors where 1 == true but still allows for properly evaluating equality WITHIN a type. For example, 1 and 1.0 maybe using different JDK classes behind the scenes (Integer and BigDecimal in the case of BoxLang), but they are both “numeric” values from a high level Boxlang/CF typing perspective, so we compare their effective numeric value. === is also an existing, documented part of the language that we can point to and say “that’s what we’re using”, not some hidden Java behavior that makes no sense.

Lucee, and Adobe MOSTLY ALMOST agree with BoxLang on our === implementation, but there are a couple edge cases where I think they got it wrong.

"B" === javacast( "char", "B" )

There is no such thing as a “char” type in CFML-- just strings. And for all practical intents and purposes, a char IS considered a string in CFML and should be compared inside of the “string” type family in the same way a Java Integer, Long, and Double are all “numeric” at a CF level . BoxLang considers chars to be strings, but Adobe and Lucee do not. They allow the underlying JDK types to creep up where they don’t belong.

To spell it out, BoxLang’s === uses these rules for normalizing values along the typing system of our language

  • Stringjava.lang.String, java.lang.Character, char[] → compared case-insensitively
  • Numeric — any java.lang.Number subclass (Integer, Short, Long, Double, Float, BigInteger, BigDecimal) → compared by numeric value
  • DateTimeDateTime, ZonedDateTime, Calendar, LocalDateTime, LocalDate, etc. → compared by epoch millis
  • Array — BoxLang Array or any java.util.List → Falls back to the object’s equals() method
  • Struct — BoxLang Struct or any java.util.Map → Falls back to the object’s equals() method
  • All Others — If both objects are the same class and implement Java’s Comparable interface, we use their compareTo() method. Otherwise, we fall back to the object’s equals() method

So, this is where we landed in BoxLang. Note our new set literal syntax, which is unique to our implementation.

set{ true, "yes", "YES", "true", 1, 1.0, "1", 01 }

image
We can see from the dump that

  • “yes” is the same as “YES” because Box Sets are case insensitive by default
  • Boolean true is not the same as string "true" because === doesn’t coerce across types
  • Numeric 1, 01, and 1.0 are the same value
  • String "1" and numeric 1 are NOT the same value because === doesn’t coerce across types

So we settled on a type-aware definition of equality, but one that’s based on our === operator, which is a documented part of our language, not something Java came up with.

Opt-in Case Sensitivity

Also note that sets can be made case sensitive like so in BoxLang

setNew( values=[ "brad", "BRAD" ], caseSensitive=true )

image

Wrapping Native Java Sets

Also, if you pass a Java set to a BoxLang set BIF, or use a BoxLang set member method on a Java Set instance, we will obey the behavior of that Java class.

import java.util.HashSet;
myJavaSet = new HashSet( "brad" );
myJavaSet.add( "BRAD" )  // Java sets are case sensitive

BIF Collisions

And one other final difference-- there have been reports in the Adobe tracker and our BoxLang ticket tracker about set BIFs overlapping UDFs of the same name since they look like common setter methods. For example, the set BIF setFilter() messes up a CFC having a filter property and calling the generated or explicit setter method setFilter( myFilter ). As such, we renamed all of the BIFs that operate on a set instance to have the word box in front to avoid collisions. So you can technically call the headless version as

boxSetFilter( mySet, i -> i=="foo" )

but we recommend just sticking with the member method syntax:

mySet.filter( i -> i=="foo" )

Docs

With that, the full breadth of Box Set functionality is available here in our docs. I only focused on the differences and reasoning behind our architecture here.

As of the date of this post, Lucee has no Set implementation.

2 Likes