diff --git a/guide/pyclass-parameters.md b/guide/pyclass-parameters.md
index 77750e36..1756e8df 100644
--- a/guide/pyclass-parameters.md
+++ b/guide/pyclass-parameters.md
@@ -11,6 +11,7 @@
| `freelist = N` | Implements a [free list][params-2] of size N. This can improve performance for types that are often created and deleted in quick succession. Profile your code to see whether `freelist` is right for you. |
| `frozen` | Declares that your pyclass is immutable. It removes the borrow checker overhead when retrieving a shared reference to the Rust struct, but disables the ability to get a mutable reference. |
| `get_all` | Generates getters for all fields of the pyclass. |
+| `hash` | Implements `__hash__` using the `Hash` implementation of the underlying Rust datatype. |
| `mapping` | Inform PyO3 that this class is a [`Mapping`][params-mapping], and so leave its implementation of sequence C-API slots empty. |
| `module = "module_name"` | Python code will see the class as being defined in this module. Defaults to `builtins`. |
| `name = "python_name"` | Sets the name that Python sees this class as. Defaults to the name of the Rust struct. |
diff --git a/guide/src/class/object.md b/guide/src/class/object.md
index e7a36687..3b775c2b 100644
--- a/guide/src/class/object.md
+++ b/guide/src/class/object.md
@@ -121,6 +121,19 @@ impl Number {
}
}
```
+To implement `__hash__` using the Rust [`Hash`] trait implementation, the `hash` option can be used.
+This option is only available for `frozen` classes to prevent accidental hash changes from mutating the object. If you need
+an `__hash__` implementation for a mutable class, use the manual method from above. This option also requires `eq`: According to the
+[Python docs](https://docs.python.org/3/reference/datamodel.html#object.__hash__) "If a class does not define an `__eq__()`
+method it should not define a `__hash__()` operation either"
+```rust
+# use pyo3::prelude::*;
+#
+#[pyclass(frozen, eq, hash)]
+#[derive(PartialEq, Hash)]
+struct Number(i32);
+```
+
> **Note**: When implementing `__hash__` and comparisons, it is important that the following property holds:
>
diff --git a/newsfragments/4206.added.md b/newsfragments/4206.added.md
new file mode 100644
index 00000000..90a74af3
--- /dev/null
+++ b/newsfragments/4206.added.md
@@ -0,0 +1 @@
+Added `#[pyclass(hash)]` option to implement `__hash__` in terms of the `Hash` implementation
diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs
index 3bccf0ae..b7ef2ae6 100644
--- a/pyo3-macros-backend/src/attributes.rs
+++ b/pyo3-macros-backend/src/attributes.rs
@@ -22,6 +22,7 @@ pub mod kw {
syn::custom_keyword!(frozen);
syn::custom_keyword!(get);
syn::custom_keyword!(get_all);
+ syn::custom_keyword!(hash);
syn::custom_keyword!(item);
syn::custom_keyword!(from_item_all);
syn::custom_keyword!(mapping);
diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs
index 5838f7c5..6c7e7d86 100644
--- a/pyo3-macros-backend/src/pyclass.rs
+++ b/pyo3-macros-backend/src/pyclass.rs
@@ -12,7 +12,7 @@ use crate::pyfunction::ConstructorAttribute;
use crate::pyimpl::{gen_py_const, PyClassMethodsType};
use crate::pymethod::{
impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, MethodAndSlotDef, PropertyType,
- SlotDef, __GETITEM__, __INT__, __LEN__, __REPR__, __RICHCMP__,
+ SlotDef, __GETITEM__, __HASH__, __INT__, __LEN__, __REPR__, __RICHCMP__,
};
use crate::utils::Ctx;
use crate::utils::{self, apply_renaming_rule, PythonDoc};
@@ -21,6 +21,7 @@ use proc_macro2::{Ident, Span, TokenStream};
use quote::{format_ident, quote, quote_spanned};
use syn::ext::IdentExt;
use syn::parse::{Parse, ParseStream};
+use syn::parse_quote_spanned;
use syn::punctuated::Punctuated;
use syn::{parse_quote, spanned::Spanned, Result, Token};
@@ -65,6 +66,7 @@ pub struct PyClassPyO3Options {
pub get_all: Option,
pub freelist: Option,
pub frozen: Option,
+ pub hash: Option,
pub mapping: Option,
pub module: Option,
pub name: Option,
@@ -85,6 +87,7 @@ enum PyClassPyO3Option {
Freelist(FreelistAttribute),
Frozen(kw::frozen),
GetAll(kw::get_all),
+ Hash(kw::hash),
Mapping(kw::mapping),
Module(ModuleAttribute),
Name(NameAttribute),
@@ -115,6 +118,8 @@ impl Parse for PyClassPyO3Option {
input.parse().map(PyClassPyO3Option::Frozen)
} else if lookahead.peek(attributes::kw::get_all) {
input.parse().map(PyClassPyO3Option::GetAll)
+ } else if lookahead.peek(attributes::kw::hash) {
+ input.parse().map(PyClassPyO3Option::Hash)
} else if lookahead.peek(attributes::kw::mapping) {
input.parse().map(PyClassPyO3Option::Mapping)
} else if lookahead.peek(attributes::kw::module) {
@@ -180,6 +185,7 @@ impl PyClassPyO3Options {
PyClassPyO3Option::Freelist(freelist) => set_option!(freelist),
PyClassPyO3Option::Frozen(frozen) => set_option!(frozen),
PyClassPyO3Option::GetAll(get_all) => set_option!(get_all),
+ PyClassPyO3Option::Hash(hash) => set_option!(hash),
PyClassPyO3Option::Mapping(mapping) => set_option!(mapping),
PyClassPyO3Option::Module(module) => set_option!(module),
PyClassPyO3Option::Name(name) => set_option!(name),
@@ -363,8 +369,12 @@ fn impl_class(
let (default_richcmp, default_richcmp_slot) =
pyclass_richcmp(&args.options, &syn::parse_quote!(#cls), ctx)?;
+ let (default_hash, default_hash_slot) =
+ pyclass_hash(&args.options, &syn::parse_quote!(#cls), ctx)?;
+
let mut slots = Vec::new();
slots.extend(default_richcmp_slot);
+ slots.extend(default_hash_slot);
let py_class_impl = PyClassImplsBuilder::new(
cls,
@@ -393,6 +403,7 @@ fn impl_class(
#[allow(non_snake_case)]
impl #cls {
#default_richcmp
+ #default_hash
}
})
}
@@ -798,9 +809,11 @@ fn impl_simple_enum(
let (default_richcmp, default_richcmp_slot) =
pyclass_richcmp_simple_enum(&args.options, &ty, repr_type, ctx);
+ let (default_hash, default_hash_slot) = pyclass_hash(&args.options, &ty, ctx)?;
let mut default_slots = vec![default_repr_slot, default_int_slot];
default_slots.extend(default_richcmp_slot);
+ default_slots.extend(default_hash_slot);
let pyclass_impls = PyClassImplsBuilder::new(
cls,
@@ -827,6 +840,7 @@ fn impl_simple_enum(
#default_repr
#default_int
#default_richcmp
+ #default_hash
}
})
}
@@ -858,9 +872,11 @@ fn impl_complex_enum(
let pytypeinfo = impl_pytypeinfo(cls, &args, None, ctx);
let (default_richcmp, default_richcmp_slot) = pyclass_richcmp(&args.options, &ty, ctx)?;
+ let (default_hash, default_hash_slot) = pyclass_hash(&args.options, &ty, ctx)?;
let mut default_slots = vec![];
default_slots.extend(default_richcmp_slot);
+ default_slots.extend(default_hash_slot);
let impl_builder = PyClassImplsBuilder::new(
cls,
@@ -967,6 +983,7 @@ fn impl_complex_enum(
#[allow(non_snake_case)]
impl #cls {
#default_richcmp
+ #default_hash
}
#(#variant_cls_zsts)*
@@ -1783,6 +1800,35 @@ fn pyclass_richcmp(
}
}
+fn pyclass_hash(
+ options: &PyClassPyO3Options,
+ cls: &syn::Type,
+ ctx: &Ctx,
+) -> Result<(Option, Option)> {
+ if options.hash.is_some() {
+ ensure_spanned!(
+ options.frozen.is_some(), options.hash.span() => "The `hash` option requires the `frozen` option.";
+ options.eq.is_some(), options.hash.span() => "The `hash` option requires the `eq` option.";
+ );
+ }
+ // FIXME: Use hash.map(...).unzip() on MSRV >= 1.66
+ match options.hash {
+ Some(opt) => {
+ let mut hash_impl = parse_quote_spanned! { opt.span() =>
+ fn __pyo3__generated____hash__(&self) -> u64 {
+ let mut s = ::std::collections::hash_map::DefaultHasher::new();
+ ::std::hash::Hash::hash(self, &mut s);
+ ::std::hash::Hasher::finish(&s)
+ }
+ };
+ let hash_slot =
+ generate_protocol_slot(cls, &mut hash_impl, &__HASH__, "__hash__", ctx).unwrap();
+ Ok((Some(hash_impl), Some(hash_slot)))
+ }
+ None => Ok((None, None)),
+ }
+}
+
/// Implements most traits used by `#[pyclass]`.
///
/// Specifically, it implements traits that only depend on class name,
diff --git a/pyo3-macros-backend/src/pymethod.rs b/pyo3-macros-backend/src/pymethod.rs
index f5b11af3..013b1501 100644
--- a/pyo3-macros-backend/src/pymethod.rs
+++ b/pyo3-macros-backend/src/pymethod.rs
@@ -910,7 +910,7 @@ impl PropertyType<'_> {
const __STR__: SlotDef = SlotDef::new("Py_tp_str", "reprfunc");
pub const __REPR__: SlotDef = SlotDef::new("Py_tp_repr", "reprfunc");
-const __HASH__: SlotDef = SlotDef::new("Py_tp_hash", "hashfunc")
+pub const __HASH__: SlotDef = SlotDef::new("Py_tp_hash", "hashfunc")
.ret_ty(Ty::PyHashT)
.return_conversion(TokenGenerator(
|Ctx { pyo3_path }: &Ctx| quote! { #pyo3_path::callback::HashCallbackOutput },
diff --git a/pyo3-macros-backend/src/utils.rs b/pyo3-macros-backend/src/utils.rs
index ca32abb4..a4c2c5e8 100644
--- a/pyo3-macros-backend/src/utils.rs
+++ b/pyo3-macros-backend/src/utils.rs
@@ -25,7 +25,20 @@ macro_rules! ensure_spanned {
if !($condition) {
bail_spanned!($span => $msg);
}
- }
+ };
+ ($($condition:expr, $span:expr => $msg:expr;)*) => {
+ if let Some(e) = [$(
+ (!($condition)).then(|| err_spanned!($span => $msg)),
+ )*]
+ .into_iter()
+ .flatten()
+ .reduce(|mut acc, e| {
+ acc.combine(e);
+ acc
+ }) {
+ return Err(e);
+ }
+ };
}
/// Check if the given type `ty` is `pyo3::Python`.
diff --git a/tests/test_class_basics.rs b/tests/test_class_basics.rs
index d0e74537..5b14e8a6 100644
--- a/tests/test_class_basics.rs
+++ b/tests/test_class_basics.rs
@@ -200,6 +200,34 @@ fn class_with_object_field() {
});
}
+#[pyclass(frozen, eq, hash)]
+#[derive(PartialEq, Hash)]
+struct ClassWithHash {
+ value: usize,
+}
+
+#[test]
+fn class_with_hash() {
+ Python::with_gil(|py| {
+ use pyo3::types::IntoPyDict;
+ let class = ClassWithHash { value: 42 };
+ let hash = {
+ use std::hash::{Hash, Hasher};
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ class.hash(&mut hasher);
+ hasher.finish() as isize
+ };
+
+ let env = [
+ ("obj", Py::new(py, class).unwrap().into_any()),
+ ("hsh", hash.into_py(py)),
+ ]
+ .into_py_dict_bound(py);
+
+ py_assert!(py, *env, "hash(obj) == hsh");
+ });
+}
+
#[pyclass(unsendable, subclass)]
struct UnsendableBase {
value: std::rc::Rc,
diff --git a/tests/test_enum.rs b/tests/test_enum.rs
index 148520dd..7bfd624a 100644
--- a/tests/test_enum.rs
+++ b/tests/test_enum.rs
@@ -220,3 +220,63 @@ fn test_renaming_all_enum_variants() {
);
});
}
+
+#[pyclass(frozen, eq, eq_int, hash)]
+#[derive(PartialEq, Hash)]
+enum SimpleEnumWithHash {
+ A,
+ B,
+}
+
+#[test]
+fn test_simple_enum_with_hash() {
+ Python::with_gil(|py| {
+ use pyo3::types::IntoPyDict;
+ let class = SimpleEnumWithHash::A;
+ let hash = {
+ use std::hash::{Hash, Hasher};
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ class.hash(&mut hasher);
+ hasher.finish() as isize
+ };
+
+ let env = [
+ ("obj", Py::new(py, class).unwrap().into_any()),
+ ("hsh", hash.into_py(py)),
+ ]
+ .into_py_dict_bound(py);
+
+ py_assert!(py, *env, "hash(obj) == hsh");
+ });
+}
+
+#[pyclass(eq, hash)]
+#[derive(PartialEq, Hash)]
+enum ComplexEnumWithHash {
+ A(u32),
+ B { msg: String },
+}
+
+#[test]
+fn test_complex_enum_with_hash() {
+ Python::with_gil(|py| {
+ use pyo3::types::IntoPyDict;
+ let class = ComplexEnumWithHash::B {
+ msg: String::from("Hello"),
+ };
+ let hash = {
+ use std::hash::{Hash, Hasher};
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ class.hash(&mut hasher);
+ hasher.finish() as isize
+ };
+
+ let env = [
+ ("obj", Py::new(py, class).unwrap().into_any()),
+ ("hsh", hash.into_py(py)),
+ ]
+ .into_py_dict_bound(py);
+
+ py_assert!(py, *env, "hash(obj) == hsh");
+ });
+}
diff --git a/tests/ui/invalid_pyclass_args.rs b/tests/ui/invalid_pyclass_args.rs
index 6e359f61..24842eb4 100644
--- a/tests/ui/invalid_pyclass_args.rs
+++ b/tests/ui/invalid_pyclass_args.rs
@@ -52,4 +52,23 @@ impl EqOptAndManualRichCmp {
#[pyclass(eq_int)]
struct NoEqInt {}
+#[pyclass(frozen, eq, hash)]
+#[derive(PartialEq)]
+struct HashOptRequiresHash;
+
+#[pyclass(hash)]
+#[derive(Hash)]
+struct HashWithoutFrozenAndEq;
+
+#[pyclass(frozen, eq, hash)]
+#[derive(PartialEq, Hash)]
+struct HashOptAndManualHash {}
+
+#[pymethods]
+impl HashOptAndManualHash {
+ fn __hash__(&self) -> u64 {
+ todo!()
+ }
+}
+
fn main() {}
diff --git a/tests/ui/invalid_pyclass_args.stderr b/tests/ui/invalid_pyclass_args.stderr
index 72da8238..8f1b671d 100644
--- a/tests/ui/invalid_pyclass_args.stderr
+++ b/tests/ui/invalid_pyclass_args.stderr
@@ -1,4 +1,4 @@
-error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
+error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
--> tests/ui/invalid_pyclass_args.rs:3:11
|
3 | #[pyclass(extend=pyo3::types::PyDict)]
@@ -46,7 +46,7 @@ error: expected string literal
24 | #[pyclass(module = my_module)]
| ^^^^^^^^^
-error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
+error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
--> tests/ui/invalid_pyclass_args.rs:27:11
|
27 | #[pyclass(weakrev)]
@@ -64,6 +64,18 @@ error: `eq_int` can only be used on simple enums.
52 | #[pyclass(eq_int)]
| ^^^^^^
+error: The `hash` option requires the `frozen` option.
+ --> tests/ui/invalid_pyclass_args.rs:59:11
+ |
+59 | #[pyclass(hash)]
+ | ^^^^
+
+error: The `hash` option requires the `eq` option.
+ --> tests/ui/invalid_pyclass_args.rs:59:11
+ |
+59 | #[pyclass(hash)]
+ | ^^^^
+
error[E0592]: duplicate definitions with name `__pymethod___richcmp____`
--> tests/ui/invalid_pyclass_args.rs:36:1
|
@@ -75,6 +87,17 @@ error[E0592]: duplicate definitions with name `__pymethod___richcmp____`
|
= note: this error originates in the attribute macro `pyclass` (in Nightly builds, run with -Z macro-backtrace for more info)
+error[E0592]: duplicate definitions with name `__pymethod___hash____`
+ --> tests/ui/invalid_pyclass_args.rs:63:1
+ |
+63 | #[pyclass(frozen, eq, hash)]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ duplicate definitions for `__pymethod___hash____`
+...
+67 | #[pymethods]
+ | ------------ other definition for `__pymethod___hash____`
+ |
+ = note: this error originates in the attribute macro `pyclass` (in Nightly builds, run with -Z macro-backtrace for more info)
+
error[E0369]: binary operation `==` cannot be applied to type `&EqOptRequiresEq`
--> tests/ui/invalid_pyclass_args.rs:33:11
|
@@ -144,3 +167,51 @@ note: candidate #2 is defined in an impl for the type `EqOptAndManualRichCmp`
40 | #[pymethods]
| ^^^^^^^^^^^^
= note: this error originates in the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0277]: the trait bound `HashOptRequiresHash: Hash` is not satisfied
+ --> tests/ui/invalid_pyclass_args.rs:55:23
+ |
+55 | #[pyclass(frozen, eq, hash)]
+ | ^^^^ the trait `Hash` is not implemented for `HashOptRequiresHash`
+ |
+help: consider annotating `HashOptRequiresHash` with `#[derive(Hash)]`
+ |
+57 + #[derive(Hash)]
+58 | struct HashOptRequiresHash;
+ |
+
+error[E0034]: multiple applicable items in scope
+ --> tests/ui/invalid_pyclass_args.rs:63:1
+ |
+63 | #[pyclass(frozen, eq, hash)]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ multiple `__pymethod___hash____` found
+ |
+note: candidate #1 is defined in an impl for the type `HashOptAndManualHash`
+ --> tests/ui/invalid_pyclass_args.rs:63:1
+ |
+63 | #[pyclass(frozen, eq, hash)]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+note: candidate #2 is defined in an impl for the type `HashOptAndManualHash`
+ --> tests/ui/invalid_pyclass_args.rs:67:1
+ |
+67 | #[pymethods]
+ | ^^^^^^^^^^^^
+ = note: this error originates in the attribute macro `pyclass` which comes from the expansion of the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0034]: multiple applicable items in scope
+ --> tests/ui/invalid_pyclass_args.rs:67:1
+ |
+67 | #[pymethods]
+ | ^^^^^^^^^^^^ multiple `__pymethod___hash____` found
+ |
+note: candidate #1 is defined in an impl for the type `HashOptAndManualHash`
+ --> tests/ui/invalid_pyclass_args.rs:63:1
+ |
+63 | #[pyclass(frozen, eq, hash)]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+note: candidate #2 is defined in an impl for the type `HashOptAndManualHash`
+ --> tests/ui/invalid_pyclass_args.rs:67:1
+ |
+67 | #[pymethods]
+ | ^^^^^^^^^^^^
+ = note: this error originates in the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info)
diff --git a/tests/ui/invalid_pyclass_enum.rs b/tests/ui/invalid_pyclass_enum.rs
index 3c6f08da..f4b94a61 100644
--- a/tests/ui/invalid_pyclass_enum.rs
+++ b/tests/ui/invalid_pyclass_enum.rs
@@ -46,4 +46,32 @@ enum NoEqInt {
B { msg: String },
}
+#[pyclass(frozen, eq, eq_int, hash)]
+#[derive(PartialEq)]
+enum SimpleHashOptRequiresHash {
+ A,
+ B,
+}
+
+#[pyclass(frozen, eq, hash)]
+#[derive(PartialEq)]
+enum ComplexHashOptRequiresHash {
+ A(i32),
+ B { msg: String },
+}
+
+#[pyclass(hash)]
+#[derive(Hash)]
+enum SimpleHashOptRequiresFrozenAndEq {
+ A,
+ B,
+}
+
+#[pyclass(hash)]
+#[derive(Hash)]
+enum ComplexHashOptRequiresEq {
+ A(i32),
+ B { msg: String },
+}
+
fn main() {}
diff --git a/tests/ui/invalid_pyclass_enum.stderr b/tests/ui/invalid_pyclass_enum.stderr
index 551d920e..d817e601 100644
--- a/tests/ui/invalid_pyclass_enum.stderr
+++ b/tests/ui/invalid_pyclass_enum.stderr
@@ -36,6 +36,24 @@ error: `eq_int` can only be used on simple enums.
43 | #[pyclass(eq_int)]
| ^^^^^^
+error: The `hash` option requires the `frozen` option.
+ --> tests/ui/invalid_pyclass_enum.rs:63:11
+ |
+63 | #[pyclass(hash)]
+ | ^^^^
+
+error: The `hash` option requires the `eq` option.
+ --> tests/ui/invalid_pyclass_enum.rs:63:11
+ |
+63 | #[pyclass(hash)]
+ | ^^^^
+
+error: The `hash` option requires the `eq` option.
+ --> tests/ui/invalid_pyclass_enum.rs:70:11
+ |
+70 | #[pyclass(hash)]
+ | ^^^^
+
error[E0369]: binary operation `==` cannot be applied to type `&SimpleEqOptRequiresPartialEq`
--> tests/ui/invalid_pyclass_enum.rs:31:11
|
@@ -103,3 +121,27 @@ help: consider annotating `ComplexEqOptRequiresPartialEq` with `#[derive(Partial
38 + #[derive(PartialEq)]
39 | enum ComplexEqOptRequiresPartialEq {
|
+
+error[E0277]: the trait bound `SimpleHashOptRequiresHash: Hash` is not satisfied
+ --> tests/ui/invalid_pyclass_enum.rs:49:31
+ |
+49 | #[pyclass(frozen, eq, eq_int, hash)]
+ | ^^^^ the trait `Hash` is not implemented for `SimpleHashOptRequiresHash`
+ |
+help: consider annotating `SimpleHashOptRequiresHash` with `#[derive(Hash)]`
+ |
+51 + #[derive(Hash)]
+52 | enum SimpleHashOptRequiresHash {
+ |
+
+error[E0277]: the trait bound `ComplexHashOptRequiresHash: Hash` is not satisfied
+ --> tests/ui/invalid_pyclass_enum.rs:56:23
+ |
+56 | #[pyclass(frozen, eq, hash)]
+ | ^^^^ the trait `Hash` is not implemented for `ComplexHashOptRequiresHash`
+ |
+help: consider annotating `ComplexHashOptRequiresHash` with `#[derive(Hash)]`
+ |
+58 + #[derive(Hash)]
+59 | enum ComplexHashOptRequiresHash {
+ |