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 { + |