Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update == and isequal semantics to match NamedTuple's #45

Merged
merged 7 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "AutoHashEquals"
uuid = "15f4f7f2-30c1-5605-9d31-71845cf9641f"
authors = ["Neal Gafter <neal@gafter.com>", "andrew cooke <andrew@acooke.org>"]
version = "1.0.0"
version = "2.0.0"

[deps]
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Expand Down
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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

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

# `@auto_hash_equals`

Expand All @@ -24,10 +24,11 @@ struct Box{T}
x::T
end
Base.hash(x::Box, h::UInt) = hash(x.x, hash(:Box, h))
Base.(:(==))(a::Box, b::Box) = isequal(a.x, b.x)
Base.(:(==))(a::Box, b::Box) = a.x == b.x
Base.isequal(a::Box, b::Box) = isequal(a.x, b.x)
```

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)`.
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)`.

## User-specified hash function

Expand Down Expand Up @@ -71,7 +72,12 @@ end
function Base._show_default(io::IO, x::Box)
AutoHashEqualsCached._show_default_auto_hash_equals_cached(io, x)
end
# Note: the definition of `==` is more complicated when there are more fields,
# in order to handle `missing` correctly. See below for a more complicated example.
function Base.:(==)(a::Box, b::Box)
a._cached_hash == b._cached_hash && Base.:(==)(a.x, b.x)
end
function Base.isequal(a::Box, b::Box)
a._cached_hash == b._cached_hash && Base.isequal(a.x, b.x)
end
function Box(x::T) where T
Expand Down Expand Up @@ -106,16 +112,34 @@ end
function Base.hash(x::Foo, h::UInt)
Base.hash(x.b, Base.hash(x.a, Base.hash(:Foo, h)))
end
function (Base).:(==)(a::Foo, b::Foo)
function Base.isequal(a::Foo, b::Foo)
Base.isequal(a.a, b.a) && Base.isequal(a.b, b.b)
end
# Returns `false` if any two fields compare as false; otherwise, `missing` if at least
# one comparison is missing. Otherwise `true`.
# This matches the semantics of `==` for Tuple's and NamedTuple's.
function Base.:(==)(a::Foo, b::Foo)
found_missing = false
cmp = a.a == b.a
cmp === false && return false
if ismissing(cmp)
found_missing = true
end
cmp = a.b == b.b
cmp === false && return false
if ismissing(cmp)
found_missing = true
end
found_missing && return missing
return true
end
```

## Specifying whether or not type arguments should be significant

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`:

```julia-repl
```julia
gafter marked this conversation as resolved.
Show resolved Hide resolved
julia> @auto_hash_equals struct Box1{T}
x::T
end
Expand Down
2 changes: 1 addition & 1 deletion src/AutoHashEquals.jl
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ include("impl.jl")
"""
@auto_hash_equals [options] struct Foo ... end

Generate `Base.hash` and `Base.==` methods for `Foo`.
Generate `Base.hash`, `Base.isequal`, and `Base.==` methods for `Foo`.

Options:

Expand Down
81 changes: 63 additions & 18 deletions src/impl.jl
Original file line number Diff line number Diff line change
Expand Up @@ -302,24 +302,69 @@ function auto_hash_equals_impl(__source__, struct_decl, fields, cache::Bool, has
end))
end

# Add the == function
equalty_impl = foldl(
(r, f) -> :($r && $isequal($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))),
fields;
init = cache ? :(a._cached_hash == b._cached_hash) : true)
if struct_decl.args[1]
# mutable structs can efficiently be compared by reference
equalty_impl = :(a === b || $equalty_impl)
end
if isnothing(where_list) || !typearg
push!(result.args, esc(:(function $Base.:(==)(a::$type_name, b::$type_name)
$equalty_impl
end)))
else
# If requested, require the type arguments be the same for two instances to be equal
push!(result.args, esc(:(function $Base.:(==)(a::$full_type_name, b::$full_type_name) where {$(where_list...)}
$equalty_impl
end)))
# Add the `==` and `isequal` functions
for eq in (==, isequal)
if eq == isequal
equalty_impl = foldl(
(r, f) -> :($r && $eq($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))),
fields;
init = cache ? :(a._cached_hash == b._cached_hash) : true)
if struct_decl.args[1]
# mutable structs can efficiently be compared by reference
# Note this optimization is only valid for `isequal`, e.g.
# a = [missing]
# a == a # missing
# isequal(a, a) # true
equalty_impl = :(a === b || $equalty_impl)
end
else
# Here we have a more complicated implementation in order to handle missings correctly.
# If any field comparison is false, we return false (even if some return missing).
# If no field comparisons are false, but one comparison missing, then we return missing.
# Otherwise we return true.
# (This matches the semantics of `==` for `Tuple`'s and `NamedTuple`'s.)

# Here we do some manual hygiene, since we will escape everything at the end
found_missing = gensym(:found_missing)
ericphanson marked this conversation as resolved.
Show resolved Hide resolved
cmp = gensym(:cmp)
equalty_impl = quote
gafter marked this conversation as resolved.
Show resolved Hide resolved
$found_missing = false
end
if cache
q = quote
$cmp = a._cached_hash == b._cached_hash
$cmp === false && return false
end
append!(equalty_impl.args, q.args)
end
for f in fields
q = quote
$cmp = $eq($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))
ericphanson marked this conversation as resolved.
Show resolved Hide resolved
$cmp === false && return false
if $ismissing($cmp)
ericphanson marked this conversation as resolved.
Show resolved Hide resolved
$found_missing = true
end
end
append!(equalty_impl.args, q.args)
end
q = quote
$found_missing && return missing
return true
end
append!(equalty_impl.args,q.args)
end

fn_name = Symbol(eq)
if isnothing(where_list) || !typearg
push!(result.args, esc(:(function (Base).$fn_name(a::$type_name, b::$type_name)
ericphanson marked this conversation as resolved.
Show resolved Hide resolved
$equalty_impl
end)))
else
# If requested, require the type arguments be the same for two instances to be equal
push!(result.args, esc(:(function (Base).$fn_name(a::$full_type_name, b::$full_type_name) where {$(where_list...)}
$equalty_impl
end)))
end
end

# Evaluating a struct declaration normally returns the struct itself.
Expand Down
58 changes: 52 additions & 6 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -249,23 +249,31 @@ end
@test hash(T135(1, :x)) == hash(serialize_and_deserialize(T135(1, :x)))
end

@testset "contained NaN values compare equal" begin
@testset "contained NaN values compare isequal (but not ==)" begin
@auto_hash_equals_cached struct T140
x
end
nan = 0.0 / 0.0
@test nan != nan
@test T140(nan) == T140(nan)
@test isequal(T140(nan), T140(nan))
@test T140(nan) != T140(nan)

end

@testset "ensure circular data structures, produced by hook or by crook, do not blow the stack" begin
@testset "circular data structures behavior" begin
@auto_hash_equals_cached struct T145
a::Array{Any,1}
end
t::T145 = T145(Any[1])
t.a[1] = t
# hash does not stack overflow thanks to the cache
@test hash(t) != 0
@test t == t
# `==` overflows
@test_throws StackOverflowError t == t
# isequal does not
@test isequal(t, t)
@test !isequal(t, T145(Any[]))
# Check printing
@test "$t" == "$(T145)(Any[$(T145)(#= circular reference @-2 =#)])"
end

Expand Down Expand Up @@ -501,13 +509,14 @@ end
@test hash(T313(1, :x)) == hash(serialize_and_deserialize(T313(1, :x)))
end

@testset "contained NaN values compare equal" begin
@testset "contained NaN values compare isequal (but not ==)" begin
@auto_hash_equals struct T330
x
end
nan = 0.0 / 0.0
@test nan != nan
@test T330(nan) == T330(nan)
@test isequal(T330(nan), T330(nan))
@test T330(nan) != T330(nan)
end

@testset "give no error if the struct contains internal constructors" begin
Expand Down Expand Up @@ -634,6 +643,43 @@ end
@test Box629{Int}(1) == Box629{Any}(1)
@test hash(Box629{Int}(1)) == hash(Box629{Any}(1))
end

@testset "== propogates missing, but `isequal` does not" begin
# Fixed by https://github.com/JuliaServices/AutoHashEquals.jl/issues/18
@auto_hash_equals struct Box18{T}
x::T
end
ret = Box18(missing) == Box18(missing)
@test ret === missing
ret = Box18(missing) == Box18(1)
@test ret === missing
@test isequal(Box18(missing), Box18(missing))
@test !isequal(Box18(missing), Box18(1))

@auto_hash_equals struct Two18{T1, T2}
x::T1
y::T2
end
ret = Two18(1, missing) == Two18(1, 2)
@test ret === missing

ret = Two18(5, missing) == Two18(1, 2)
@test ret === false

ret = Two18(missing, 2) == Two18(1, 2)
@test ret === missing

ret = Two18(missing, 5) == Two18(1, 2)
@test ret === false

@auto_hash_equals mutable struct MutBox18{T}
x::T
end
b = MutBox18(missing)
ret = b == b
@test ret === missing
@test isequal(b, b)
end
end
end

Expand Down