Skip to content

Commit 0a1e379

Browse files
ericphansongafter
andauthored
Update == and isequal semantics to match NamedTuple's (#45)
Specifically: - Define `==` in terms of `==` of members, properly handing `missing`. - Define `isequal` in terms of `isequal` of members. - Add an option `compat1=true` for clients who prefer the old behavior. - This code tickles a bug in Julia that apparently is fixed in 1.8, so that is the new min version. - Bump version to `2.0.0` Co-authored-by: Neal Gafter <[email protected]>
1 parent 8967719 commit 0a1e379

File tree

6 files changed

+192
-39
lines changed

6 files changed

+192
-39
lines changed

.github/workflows/CI.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@ jobs:
1818
fail-fast: false
1919
matrix:
2020
version:
21-
- '1.6'
22-
- '1.7'
2321
- '1.8'
22+
- '1.8.2'
2423
- '1.9'
2524
- 'nightly'
2625
os:

Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
name = "AutoHashEquals"
22
uuid = "15f4f7f2-30c1-5605-9d31-71845cf9641f"
33
authors = ["Neal Gafter <[email protected]>", "andrew cooke <[email protected]>"]
4-
version = "1.1.0"
4+
version = "2.0.0"
55

66
[deps]
77
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
88

99
[compat]
10-
julia = "1.6"
10+
julia = "1.8"

README.md

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
# AutoHashEquals.jl - Automatically define hash and equals for Julia.
55

6-
A macro to add `==` and `hash()` to struct types: `@auto_hash_equals`.
6+
A macro to add `isequal`, `==`, and `hash()` to struct types: `@auto_hash_equals`.
77

88
# `@auto_hash_equals`
99

@@ -24,10 +24,11 @@ struct Box{T}
2424
x::T
2525
end
2626
Base.hash(x::Box, h::UInt) = hash(x.x, hash(:Box, h))
27-
Base.(:(==))(a::Box, b::Box) = isequal(a.x, b.x)
27+
Base.(:(==))(a::Box, b::Box) = a.x == b.x
28+
Base.isequal(a::Box, b::Box) = isequal(a.x, b.x)
2829
```
2930

30-
We do not take the type arguments of a generic type into account for either `hash` or `==` unless `typearg=true` is specified (see below). So a `Box{Int}(1)` will test equal to a `Box{Any}(1)`.
31+
We do not take the type arguments of a generic type into account for `isequal`, `hash`, or `==` unless `typearg=true` is specified (see below). So by default, a `Box{Int}(1)` will test equal to a `Box{Any}(1)`.
3132

3233
## User-specified hash function
3334

@@ -71,7 +72,12 @@ end
7172
function Base._show_default(io::IO, x::Box)
7273
AutoHashEqualsCached._show_default_auto_hash_equals_cached(io, x)
7374
end
75+
# Note: the definition of `==` is more complicated when there are more fields,
76+
# in order to handle `missing` correctly. See below for a more complicated example.
7477
function Base.:(==)(a::Box, b::Box)
78+
a._cached_hash == b._cached_hash && Base.:(==)(a.x, b.x)
79+
end
80+
function Base.isequal(a::Box, b::Box)
7581
a._cached_hash == b._cached_hash && Base.isequal(a.x, b.x)
7682
end
7783
function Box(x::T) where T
@@ -106,16 +112,34 @@ end
106112
function Base.hash(x::Foo, h::UInt)
107113
Base.hash(x.b, Base.hash(x.a, Base.hash(:Foo, h)))
108114
end
109-
function (Base).:(==)(a::Foo, b::Foo)
115+
function Base.isequal(a::Foo, b::Foo)
110116
Base.isequal(a.a, b.a) && Base.isequal(a.b, b.b)
111117
end
118+
# Returns `false` if any two fields compare as false; otherwise, `missing` if at least
119+
# one comparison is missing. Otherwise `true`.
120+
# This matches the semantics of `==` for Tuple's and NamedTuple's.
121+
function Base.:(==)(a::Foo, b::Foo)
122+
found_missing = false
123+
cmp = a.a == b.a
124+
cmp === false && return false
125+
if ismissing(cmp)
126+
found_missing = true
127+
end
128+
cmp = a.b == b.b
129+
cmp === false && return false
130+
if ismissing(cmp)
131+
found_missing = true
132+
end
133+
found_missing && return missing
134+
return true
135+
end
112136
```
113137

114138
## Specifying whether or not type arguments should be significant
115139

116140
You can specify that type arguments should be significant for the purposes of computing the hash function and checking equality by adding the keyword parameter `typearg=true`. By default they are not significant. You can specify the default (they are not significant) with `typearg=false`:
117141

118-
```julia-repl
142+
```julia
119143
julia> @auto_hash_equals struct Box1{T}
120144
x::T
121145
end
@@ -161,3 +185,32 @@ If `typearg=true`, then `e(t)` is used as the type seed, where `t` is the type o
161185

162186
Note that the value of `typeseed` is expected to be a `UInt` value when `typearg=false` (or `typearg` is not specified),
163187
but a function that takes a type as its argument when `typearg=true`.
188+
189+
## Compatibility mode
190+
191+
In versions `v"1.0"` and earlier of `AutoHashEquals`, we produced a specialization of `Base.==`, implemented using `Base.isequal`.
192+
This was not correct.
193+
See https://docs.julialang.org/en/v1/base/base/#Base.isequal and https://docs.julialang.org/en/v1/base/math/#Base.:==.
194+
More correct would be to define `==` by using `==` on the members, and to define `isequal` by using `isequal` on the members.
195+
In version `v"2.0"` we provide a correct implementation, thanks to @ericphanson.
196+
197+
To get the same behavior as `v"1.0"` of this package, in which `==` is implemented based on `isequal`,
198+
you can specify `compat1=true`.
199+
200+
```julia
201+
@auto_hash_equals struct Box890{T}
202+
x::T
203+
end
204+
@assert ismissing(Box890(missing) == Box890(missing))
205+
@assert isequal(Box890(missing), Box890(missing))
206+
@assert ismissing(Box890(missing) == Box890(1))
207+
@assert !isequal(Box890(missing), Box890(1))
208+
209+
@auto_hash_equals compat1=true struct Box891{T}
210+
x::T
211+
end
212+
@assert Box891(missing) == Box891(missing)
213+
@assert isequal(Box891(missing), Box891(missing))
214+
@assert Box891(missing) != Box891(1)
215+
@assert !isequal(Box891(missing), Box891(1))
216+
```

src/AutoHashEquals.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ include("impl.jl")
1010
"""
1111
@auto_hash_equals [options] struct Foo ... end
1212
13-
Generate `Base.hash` and `Base.==` methods for `Foo`.
13+
Generate `Base.hash`, `Base.isequal`, and `Base.==` methods for `Foo`.
1414
1515
Options:
1616
@@ -19,6 +19,7 @@ Options:
1919
* `fields=a,b,c` the fields to use for hashing and equality. Default: all fields.
2020
* `typearg=true|false` whether or not to make type arguments significant. Default: `false`.
2121
* `typeseed=e` Use `e` (or `e(type)` if `typearg=true`) as the seed for hashing type arguments.
22+
* `compat1=true` To have `==` defined by using `isequal`. Default: `false`.
2223
"""
2324
macro auto_hash_equals(args...)
2425
kwargs = Dict{Symbol,Any}()

src/impl.jl

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ function auto_hash_equals_impl(__source__::LineNumberNode, typ; kwargs...)
153153
fields=nothing
154154
typearg=false
155155
typeseed=nothing
156+
compat1=false
156157

157158
# Process the keyword arguments
158159
for kw in kwargs
@@ -181,17 +182,22 @@ function auto_hash_equals_impl(__source__::LineNumberNode, typ; kwargs...)
181182
fields = kw.second
182183
elseif kw.first === :typeseed
183184
typeseed = kw.second
185+
elseif kw.first === :compat1
186+
if !(kw.second isa Bool)
187+
error_usage(__source__, "`compat1` argument must be a Bool, but got `$(kw.second)`.")
188+
end
189+
compat1 = kw.second
184190
else
185191
error_usage(__source__, "invalid keyword argument for @auto_hash_equals: `$(kw.first)`.")
186192
end
187193
end
188194

189195
typ = get_struct_decl(__source__::LineNumberNode, typ)
190196

191-
auto_hash_equals_impl(__source__, typ, fields, cache, hashfn, typearg, typeseed)
197+
auto_hash_equals_impl(__source__, typ, fields, cache, hashfn, typearg, typeseed, compat1)
192198
end
193199

194-
function auto_hash_equals_impl(__source__, struct_decl, fields, cache::Bool, hashfn, typearg::Bool, typeseed)
200+
function auto_hash_equals_impl(__source__, struct_decl, fields, cache::Bool, hashfn, typearg::Bool, typeseed, compat1::Bool)
195201
is_expr(struct_decl, :struct) || error_usage(__source__)
196202

197203
type_body = struct_decl.args[3].args
@@ -335,24 +341,56 @@ function auto_hash_equals_impl(__source__, struct_decl, fields, cache::Bool, has
335341
end))
336342
end
337343

338-
# Add the == function
339-
equalty_impl = foldl(
340-
(r, f) -> :($r && $isequal($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))),
341-
fields;
342-
init = cache ? :(a._cached_hash == b._cached_hash) : true)
343-
if struct_decl.args[1]
344-
# mutable structs can efficiently be compared by reference
345-
equalty_impl = :(a === b || $equalty_impl)
346-
end
347-
if isnothing(where_list) || !typearg
348-
push!(result.args, esc(:(function $Base.:(==)(a::$type_name, b::$type_name)
349-
$equalty_impl
350-
end)))
351-
else
352-
# If requested, require the type arguments be the same for two instances to be equal
353-
push!(result.args, esc(:(function $Base.:(==)(a::$full_type_name, b::$full_type_name) where {$(where_list...)}
354-
$equalty_impl
355-
end)))
344+
# Add the `==` and `isequal` functions
345+
for eq in (==, isequal)
346+
# In compat mode, only define ==
347+
eq == isequal && compat1 && continue
348+
349+
if eq == isequal || compat1
350+
equality_impl = foldl(
351+
(r, f) -> :($r && $isequal($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))),
352+
fields;
353+
init = cache ? :(a._cached_hash == b._cached_hash) : true)
354+
if struct_decl.args[1]
355+
# mutable structs can efficiently be compared by reference
356+
# Note this optimization is only valid for `isequal`, e.g.
357+
# a = [missing]
358+
# a == a # missing
359+
# isequal(a, a) # true
360+
equality_impl = :(a === b || $equality_impl)
361+
end
362+
else
363+
# Julia library defines `isequal` in terms of `==`.
364+
compat1 && continue
365+
366+
# Here we have a more complicated implementation in order to handle missings correctly.
367+
# If any field comparison is false, we return false (even if some return missing).
368+
# If no field comparisons are false, but one comparison missing, then we return missing.
369+
# Otherwise we return true.
370+
# (This matches the semantics of `==` for `Tuple`'s and `NamedTuple`'s.)
371+
equality_impl = Expr(:block, :(found_missing = false))
372+
if cache
373+
push!(equality_impl.args, :(a._cached_hash != b._cached_hash && return false))
374+
end
375+
for f in fields
376+
push!(equality_impl.args, :(cmp = $getfield(a, $(QuoteNode(f))) == $getfield(b, $(QuoteNode(f)))))
377+
push!(equality_impl.args, :(cmp === false && return false))
378+
push!(equality_impl.args, :($ismissing(cmp) && (found_missing = true)))
379+
end
380+
push!(equality_impl.args, :(return $ifelse(found_missing, missing, true)))
381+
end
382+
383+
fn_name = Symbol(eq)
384+
if isnothing(where_list) || !typearg
385+
push!(result.args, esc(:(function ($Base).$fn_name(a::$type_name, b::$type_name)
386+
$equality_impl
387+
end)))
388+
else
389+
# If requested, require the type arguments be the same for two instances to be equal
390+
push!(result.args, esc(:(function ($Base).$fn_name(a::$full_type_name, b::$full_type_name) where {$(where_list...)}
391+
$equality_impl
392+
end)))
393+
end
356394
end
357395

358396
# Evaluating a struct declaration normally returns the struct itself.

test/runtests.jl

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ function serialize_and_deserialize(x)
2020
end
2121

2222
macro noop(x)
23-
esc(quote
24-
Base.@__doc__$(x)
25-
end)
23+
esc(x)
2624
end
2725

2826
macro _const(x)
@@ -261,23 +259,31 @@ abstract type B{T} end
261259
@test hash(T135(1, :x)) == hash(serialize_and_deserialize(T135(1, :x)))
262260
end
263261

264-
@testset "contained NaN values compare equal" begin
262+
@testset "contained NaN values compare isequal (but not ==)" begin
265263
@auto_hash_equals_cached struct T140
266264
x
267265
end
268266
nan = 0.0 / 0.0
269267
@test nan != nan
270-
@test T140(nan) == T140(nan)
268+
@test isequal(T140(nan), T140(nan))
269+
@test T140(nan) != T140(nan)
270+
271271
end
272272

273-
@testset "ensure circular data structures, produced by hook or by crook, do not blow the stack" begin
273+
@testset "circular data structures behavior" begin
274274
@auto_hash_equals_cached struct T145
275275
a::Array{Any,1}
276276
end
277277
t::T145 = T145(Any[1])
278278
t.a[1] = t
279+
# hash does not stack overflow thanks to the cache
279280
@test hash(t) != 0
280-
@test t == t
281+
# `==` overflows
282+
@test_throws StackOverflowError t == t
283+
# isequal does not
284+
@test isequal(t, t)
285+
@test !isequal(t, T145(Any[]))
286+
# Check printing
281287
@test "$t" == "$(T145)(Any[$(T145)(#= circular reference @-2 =#)])"
282288
end
283289

@@ -513,13 +519,14 @@ abstract type B{T} end
513519
@test hash(T313(1, :x)) == hash(serialize_and_deserialize(T313(1, :x)))
514520
end
515521

516-
@testset "contained NaN values compare equal" begin
522+
@testset "contained NaN values compare isequal (but not ==)" begin
517523
@auto_hash_equals struct T330
518524
x
519525
end
520526
nan = 0.0 / 0.0
521527
@test nan != nan
522-
@test T330(nan) == T330(nan)
528+
@test isequal(T330(nan), T330(nan))
529+
@test T330(nan) != T330(nan)
523530
end
524531

525532
@testset "give no error if the struct contains internal constructors" begin
@@ -840,6 +847,61 @@ abstract type B{T} end
840847
@test_throws MethodError hash(S681(1))
841848
end
842849

850+
851+
@testset "== propogates missing, but `isequal` does not" begin
852+
# Fixed by https://github.com/JuliaServices/AutoHashEquals.jl/issues/18
853+
@auto_hash_equals struct Box18{T}
854+
x::T
855+
end
856+
ret = Box18(missing) == Box18(missing)
857+
@test ret === missing
858+
ret = Box18(missing) == Box18(1)
859+
@test ret === missing
860+
@test isequal(Box18(missing), Box18(missing))
861+
@test !isequal(Box18(missing), Box18(1))
862+
863+
@auto_hash_equals struct Two18{T1, T2}
864+
x::T1
865+
y::T2
866+
end
867+
ret = Two18(1, missing) == Two18(1, 2)
868+
@test ret === missing
869+
870+
ret = Two18(5, missing) == Two18(1, 2)
871+
@test ret === false
872+
873+
ret = Two18(missing, 2) == Two18(1, 2)
874+
@test ret === missing
875+
876+
ret = Two18(missing, 5) == Two18(1, 2)
877+
@test ret === false
878+
879+
@auto_hash_equals mutable struct MutBox18{T}
880+
x::T
881+
end
882+
b = MutBox18(missing)
883+
ret = b == b
884+
@test ret === missing
885+
@test isequal(b, b)
886+
end
887+
888+
@testset "test the compat1 flag" begin
889+
@auto_hash_equals struct Box890{T}
890+
x::T
891+
end
892+
@test ismissing(Box890(missing) == Box890(missing))
893+
@test isequal(Box890(missing), Box890(missing))
894+
@test ismissing(Box890(missing) == Box890(1))
895+
@test !isequal(Box890(missing), Box890(1))
896+
897+
@auto_hash_equals compat1=true struct Box891{T}
898+
x::T
899+
end
900+
@test Box891(missing) == Box891(missing)
901+
@test isequal(Box891(missing), Box891(missing))
902+
@test Box891(missing) != Box891(1)
903+
@test !isequal(Box891(missing), Box891(1))
904+
end
843905
end
844906
end
845907

0 commit comments

Comments
 (0)