Skip to content
This repository has been archived by the owner on Aug 18, 2023. It is now read-only.

Commit

Permalink
Move to a single macro, @auto_hash_equals, and keyword options.
Browse files Browse the repository at this point in the history
  • Loading branch information
gafter committed Aug 9, 2023
1 parent 4af0a63 commit 84f2fce
Show file tree
Hide file tree
Showing 4 changed files with 477 additions and 328 deletions.
12 changes: 1 addition & 11 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
name = "AutoHashEqualsCached"
uuid = "887ad303-ad3a-4bc5-965e-507c643d6945"
authors = ["Neal Gafter <[email protected]> and contributors"]
version = "0.3.0"
version = "1.0.0"

[deps]
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Match = "7eb4fadd-790c-5f42-8a69-bfa0b872bfbf"

[compat]
Match = "2"
julia = "1.6"

[extras]
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Match = "7eb4fadd-790c-5f42-8a69-bfa0b872bfbf"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test"]
350 changes: 37 additions & 313 deletions src/AutoHashEqualsCached.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,339 +2,63 @@

module AutoHashEqualsCached

using Pkg
export @auto_hash_equals

export @auto_hash_equals_cached, @auto_hash_equals
include("impl.jl")

pkgversion(m::Module) = VersionNumber(Pkg.TOML.parsefile(joinpath(dirname(string(first(methods(m.eval)).file)), "..", "Project.toml"))["version"])

function if_has_package(
action::Function,
name::String,
uuid::Base.UUID,
version::VersionNumber
)
pkgid = Base.PkgId(uuid, name)
if Base.root_module_exists(pkgid)
pkg = Base.root_module(pkgid)
if pkgversion(pkg) >= version
return action(pkg)
end
end
end

# `_show_default_auto_hash_equals_cached` is just like `Base._show_default(io, x)`,
# except it ignores fields named `_cached_hash`. This function is called in the
# implementation of `T._show_default` for each type `T` annotated with
# `@auto_hash_equals_cached`. This is ultimately used in the implementation of
# `Base.show`. This specialization ensures that showing circular data structures does not
# result in infinite recursion.
function _show_default_auto_hash_equals_cached(io::IO, @nospecialize(x))
t = typeof(x)
show(io, Base.inferencebarrier(t)::DataType)
print(io, '(')
recur_io = IOContext(io, Pair{Symbol,Any}(:SHOWN_SET, x),
Pair{Symbol,Any}(:typeinfo, Any))
if !Base.show_circular(io, x)
for i in 1:nfields(x)
f = fieldname(t, i)
if (f === :_cached_hash)
continue
elseif i > 1
print(io, ", ")
end
if !isdefined(x, f)
print(io, Base.undef_ref_str)
else
show(recur_io, getfield(x, i))
end
end
end
print(io,')')
end

# Find the first struct declaration buried in the Expr.
get_struct_decl(__source__, typ) = nothing
function get_struct_decl(__source__, typ::Expr)
if typ.head === :struct
return typ
elseif typ.head === :macrocall
return get_struct_decl(__source__, typ.args[3])
elseif typ.head === :block
# get the first struct decl in the block
for x in typ.args
if x isa LineNumberNode
__source__ = x
elseif x isa Expr && (x.head === :macrocall || x.head === :struct || x.head === :block)
result = get_struct_decl(__source__, x)
if !isnothing(result)
return result
end
end
end
end

error("$(__source__.file):$(__source__.line): macro @auto_hash_equals_cached should only be applied to a struct")
end
"""
@auto_hash_equals [options] struct Foo ... end
unpack_name(node) = node
function unpack_name(node::Expr)
if node.head === :macrocall
return unpack_name(node.args[3])
elseif node.head in (:(<:), :(::))
return unpack_name(node.args[1])
else
return node
end
end
Generate `Base.hash` and `Base.==` methods for `Foo`.
unpack_type_name(__source__, n::Symbol) = (n, n, nothing)
function unpack_type_name(__source__, n::Expr)
if n.head === :curly
type_name = n.args[1]
type_name isa Symbol ||
error("$(__source__.file):$(__source__.line): macro @auto_hash_equals_cached applied to type with invalid signature: $type_name")
where_list = n.args[2:length(n.args)]
type_params = map(unpack_name, where_list)
full_type_name = Expr(:curly, type_name, type_params...)
return (type_name, full_type_name, where_list)
elseif n.head === :(<:)
return unpack_type_name(__source__, n.args[1])
else
error("$(__source__.file):$(__source__.line): macro @auto_hash_equals_cached applied to type with unexpected signature: $n")
end
end
Options:
function get_fields(__source__, struct_decl::Expr; prevent_inner_constructors=false)
member_names = Vector{Symbol}()
member_decls = Vector()

add_field(__source__, b) = nothing
function add_field(__source__, b::Symbol)
push!(member_names, b)
push!(member_decls, b)
end
function add_field(__source__, b::Expr)
if b.head === :block
add_fields(field)
elseif b.head === :const
add_field(__source__, b.args[1])
elseif b.head === :(::) && b.args[1] isa Symbol
push!(member_names, b.args[1])
push!(member_decls, b)
elseif b.head === :macrocall
add_field(__source__, b.args[3])
elseif b.head === :function || b.head === :(=) && (b.args[1] isa Expr && b.args[1].head in (:call, :where))
# :function, :equals:call, :equals:where are defining functions - inner constructors
# we don't want to permit that if it would interfere with us producing them.
prevent_inner_constructors &&
error("$(__source__.file):$(__source__.line): macro @auto_hash_equals_cached should not be used on a struct that declares an inner constructor")
* `cache=true|false` whether or not to generate an extra cache field to store the precomputed hash value. Default: `false`.
* `hashfn=myhash` the hash function to use. Default: `Base.hash`.
* `fields=a,b,c` the fields to use for hashing and equality. Default: all fields.
"""
macro auto_hash_equals(args...)
kwargs = Dict{Symbol,Any}()
length(args) > 0 || error_usage(__source__)
for option in args[1:length(args)-1]
if !isexpr(option, :(=), 2) || !(option.args[1] isa Symbol)
error("$(__source__.file):$(__source__.line): expected keyword argument of the form `key=value`, but saw `$option`")

Check warning on line 25 in src/AutoHashEqualsCached.jl

View check run for this annotation

Codecov / codecov/patch

src/AutoHashEqualsCached.jl#L25

Added line #L25 was not covered by tests
end
end
function add_fields(__source__, b::Expr)
@assert b.head === :block
for field in b.args
if field isa LineNumberNode
__source__ = field
name=option.args[1]
value=option.args[2]
if name == :fields
# fields=a,b,c
if value isa Symbol
value = (value,)

Check warning on line 32 in src/AutoHashEqualsCached.jl

View check run for this annotation

Codecov / codecov/patch

src/AutoHashEqualsCached.jl#L32

Added line #L32 was not covered by tests
elseif isexpr(value, :tuple)
value = Symbol[value.args...]
value=(value...,)
else
add_field(__source__, field)
error("$(__source__.file):$(__source__.line): expected tuple or symbol for `fields`, but got `$value`")

Check warning on line 37 in src/AutoHashEqualsCached.jl

View check run for this annotation

Codecov / codecov/patch

src/AutoHashEqualsCached.jl#L37

Added line #L37 was not covered by tests
end
end
kwargs[name] = value
end

@assert (struct_decl.args[3].head === :block)
add_fields(__source__, struct_decl.args[3])
return (member_names, member_decls)
end

function check_valid_alt_hash_name(__source__, alt_hash_name)
alt_hash_name === nothing || alt_hash_name isa Symbol || Base.is_expr(alt_hash_name, :.) ||
error("$(__source__.file):$(__source__.line): invalid alternate hash function name: $alt_hash_name")
end

function auto_hash_equals_impl(__source__::LineNumberNode, alt_hash_name, typ::Expr)
check_valid_alt_hash_name(__source__, alt_hash_name)
struct_decl = get_struct_decl(__source__, typ)
@assert struct_decl.head === :struct

(type_name, _, _) = unpack_type_name(__source__, struct_decl.args[2])
@assert type_name isa Symbol

(member_names, _) = get_fields(__source__, struct_decl)

equalty_impl = foldl(
(r, f) -> :($r && $isequal($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))),
member_names;
init = :true)
if struct_decl.args[1]
# mutable structs can efficiently be compared by reference
equalty_impl = :(a === b || $equalty_impl)
end

result = Expr(:block, __source__, esc(:(Base.@__doc__ $typ)), __source__)

# add function for hash(x, h)
base_hash_name = :($Base.hash)
defined_hash_name = alt_hash_name === nothing ? base_hash_name : alt_hash_name
compute_hash = foldl(
(r, a) -> :($defined_hash_name($getfield(x, $(QuoteNode(a))), $r)),
member_names;
init = :($defined_hash_name($(QuoteNode(type_name)), h)))
push!(result.args, esc(:(function $defined_hash_name(x::$type_name, h::UInt)
$compute_hash
end)))
if defined_hash_name != base_hash_name
# add function for Base.hash(x, h)
push!(result.args, esc(:(function $base_hash_name(x::$type_name, h::UInt)
$defined_hash_name(x, h)
end)))
end

# for compatibility with [AutoHashEquals.jl](https://github.com/andrewcooke/AutoHashEquals.jl)
# we do not require that the types (specifically, the type arguments) are the same for two
# objects to be considered `==`.
push!(result.args, esc(:(function $Base.:(==)(a::$type_name, b::$type_name)
$equalty_impl
end)))

# push!(result.args, esc(:()))
return result
end

function auto_hash_equals_cached_impl(__source__::LineNumberNode, alt_hash_name, typ::Expr)
check_valid_alt_hash_name(__source__, alt_hash_name)
struct_decl = get_struct_decl(__source__, typ)
@assert struct_decl.head === :struct
type_body = struct_decl.args[3].args

!struct_decl.args[1] ||
error("$(__source__.file):$(__source__.line): macro @auto_hash_equals_cached should only be applied to a non-mutable struct.")

(type_name, full_type_name, where_list) = unpack_type_name(__source__, struct_decl.args[2])
@assert type_name isa Symbol

(member_names, member_decls) = get_fields(__source__, struct_decl; prevent_inner_constructors=true)

# Add the cache field to the body of the struct
push!(type_body, :(_cached_hash::UInt))

# Add the internal constructor
base_hash_name = :($Base.hash)
defined_hash_name = alt_hash_name === nothing ? base_hash_name : alt_hash_name
compute_hash = foldl(
(r, a) -> :($defined_hash_name($a, $r)),
member_names;
init = :($defined_hash_name($full_type_name)))
ctor_body = :(new($(member_names...), $compute_hash))
if isnothing(where_list)
push!(type_body, :(function $full_type_name($(member_names...))
$ctor_body
end))
else
push!(type_body, :(function $full_type_name($(member_names...)) where {$(where_list...)}
$ctor_body
end))
end

result = Expr(:block, __source__, esc(:(Base.@__doc__ $typ)), __source__)

# add function for hash(x, h). hash(x)
push!(result.args, esc(:(function $defined_hash_name(x::$type_name, h::UInt)
$defined_hash_name(x._cached_hash, h)
end)))
push!(result.args, esc(:(function $defined_hash_name(x::$type_name)
x._cached_hash
end)))
if defined_hash_name != base_hash_name
# add function for Base.hash(x, h), Base.hash(x)
push!(result.args, esc(:(function $base_hash_name(x::$type_name, h::UInt)
$defined_hash_name(x, h)
end)))
push!(result.args, esc(:(function $base_hash_name(x::$type_name)
$defined_hash_name(x)
end)))
end

# add function Base.show
push!(result.args, esc(:(function $Base._show_default(io::IO, x::$type_name)
$_show_default_auto_hash_equals_cached(io, x)
end)))

# Add functions to interoperate with Match if loaded
# at the time the macro is expanded.
if_has_package("Match", Base.UUID("7eb4fadd-790c-5f42-8a69-bfa0b872bfbf"), v"2") do pkg
if :match_fieldnames in names(pkg; all=true)
push!(result.args, esc(:(function $pkg.match_fieldnames(::Type{$type_name})
$((member_names...,))
end)))
end
end

equalty_impl = foldl(
(r, f) -> :($r && $isequal($getfield(a, $(QuoteNode(f))), $getfield(b, $(QuoteNode(f))))),
member_names;
init = :(a._cached_hash == b._cached_hash))

if isnothing(where_list)
# add == for non-generic types
push!(result.args, esc(quote
function $Base.:(==)(a::$type_name, b::$type_name)
$equalty_impl
end
end))
else
# We require the type be the same (including type arguments) for two instances to be equal
push!(result.args, esc(quote
function $Base.:(==)(a::$full_type_name, b::$full_type_name) where {$(where_list...)}
$equalty_impl
end
end))
# for generic types, we add an external constructor to perform ctor type inference:
push!(result.args, esc(quote
$type_name($(member_decls...)) where {$(where_list...)} = $full_type_name($(member_names...))
end))
end

return result
typ = args[end]
auto_hash_equals_impl(__source__, typ; kwargs...)
end

"""
@auto_hash_equals_cached struct Foo ... end
Causes the struct to have an additional hidden field named `_cached_hash` that is
computed and stored at the time of construction. Produces constructors and specializes
the behavior of `Base.show` to maintain the illusion that the field does not exist.
Two different instantiations of a generic type are considered not equal.
Also produces specializations of `Base.hash` and `Base.==`:
- `Base.==` is implemented as an elementwise test for `isequal`.
- `Base.hash` just returns the cached hash value.
Shorthand for @auto_hash_equals cache=true struct Foo ... end
"""
macro auto_hash_equals_cached(typ::Expr)
auto_hash_equals_cached_impl(__source__, nothing, typ)
end
macro auto_hash_equals_cached(alt_hash_name, typ::Expr)
auto_hash_equals_cached_impl(__source__, alt_hash_name, typ)
macro auto_hash_equals_cached(typ)
esc(Expr(:macrocall, var"@auto_hash_equals", __source__, :(cache = true), typ))
end

"""
@auto_hash_equals struct Foo ... end
@auto_hash_equals_cached althashfunction struct Foo ... end
Produces specializations of `Base.hash` and `Base.==`:
- `Base.==` is implemented as an elementwise test for `isequal`.
- `Base.hash` combines the elementwise hash code of the fields with the hash code of the type's simple name.
The hash code and `==` implementations ignore type parameters, so that `Box{Int}(1)`
will be considered `isequal` to `Box{Any}(1)`. This is for compatibility with the
package `AutoHashEquals.jl`.
Shorthand for @auto_hash_equals hashfn=althashfunction cache=true struct Foo ... end
"""
macro auto_hash_equals(typ::Expr)
auto_hash_equals_impl(__source__, nothing, typ)
end
macro auto_hash_equals(alt_hash_name, typ::Expr)
auto_hash_equals_impl(__source__, alt_hash_name, typ)
macro auto_hash_equals_cached(althashfunction, typ)
esc(Expr(:macrocall, :var"@auto_hash_equals", __source__, :(hashfn = $althashfunction), :(cache = true), typ))
end

end
end # module
Loading

0 comments on commit 84f2fce

Please sign in to comment.