Skip to content

[Python] Structural values ignore custom equality and hash codes #4834

Closed

Description

@caroott

An ordinary F# class can override Equals and GetHashCode without opting into Python's __eq__ and __hash__ protocols. Direct F# equality calls the generated Equals method, but generic hashing and structural values do not consistently use those F# methods.

With Fable 5.11.0:

  • direct class equality uses Equals, but hash can use Python's identity hash instead of GetHashCode
  • a union containing the class compares and hashes its field with Python identity semantics
  • record equality uses Equals, but record hashing uses the field's Python identity hash
  • array hashing has the same problem for its items

This is related to #4805, but a separate case. #4805 restores __hash__ on generated union case dataclasses. Once hashing is available, Union.GetHashCode still hashes its fields through Python, so an ordinary F# class without Python marker interfaces keeps its identity hash.

Minimal reproduction

Repro.fsproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Program.fs" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Fable.Core" Version="5.2.0" />
  </ItemGroup>
</Project>

Program.fs:

module Program

type CustomValue(value: int, label: string) =
    member _.Value = value
    member _.Label = label

    override _.Equals(other) =
        match other with
        | :? CustomValue as other -> value = other.Value
        | _ -> false

    override _.GetHashCode() = value

type WrappedUnion =
    | WrappedUnion of CustomValue

type WrappedRecord =
    { Value: CustomValue }

let left = CustomValue(1, "left")
let right = CustomValue(1, "right")

let leftUnion = WrappedUnion left
let rightUnion = WrappedUnion right

let leftRecord = { Value = left }
let rightRecord = { Value = right }

let leftArray = [| left |]
let rightArray = [| right |]

printfn "class equality: %b" (left = right)
printfn "class hash: %b" (hash left = hash right)
printfn "union equality: %b" (leftUnion = rightUnion)
printfn "union hash: %b" (hash leftUnion = hash rightUnion)
printfn "record equality: %b" (leftRecord = rightRecord)
printfn "record hash: %b" (hash leftRecord = hash rightRecord)
printfn "array equality: %b" (leftArray = rightArray)
printfn "array hash: %b" (hash leftArray = hash rightArray)

Commands:

dotnet new tool-manifest
dotnet tool install fable --version 5.11.0

python -m venv .venv
# Activate the virtual environment using the command for your shell.
python -m pip install fable-library==5.11.0

dotnet run --project Repro.fsproj
dotnet fable Repro.fsproj --lang python --outDir out --noCache
python out/program.py

Expected behavior

.NET prints true for all eight checks. Python should preserve the same F# equality and hash-code contracts:

class equality: true
class hash: true
union equality: true
union hash: true
record equality: true
record hash: true
array equality: true
array hash: true

Actual behavior

The Python target uses the custom equality for the class and record, but not for the union field. Hashing equivalent class instances produces different values, and that identity hash leaks into unions, records, and arrays:

class equality: true
class hash: false
union equality: false
union hash: false
record equality: true
record hash: false
array equality: true
array hash: false

Probable root cause

There are several paths to the same mismatch:

  1. safe_hash checks for a callable Python __hash__ before checking for F# GetHashCode. Ordinary Python objects inherit object.__hash__, so a generated F# class reaches the identity-hash branch first
  2. @tagged_union applies dataclass(cls) with generated Python equality. The generated __eq__ compares fields with Python == instead of Fable's equals helper
  3. Union.Equals also compares self.fields == other.fields, and Union.GetHashCode hashes a Python tuple containing the fields
  4. record_get_hashcode hashes a Python tuple containing the record fields
  5. structural_hash calls Python hash directly, and array_hash applies it to every item

The TypeScript runtime avoids these native-container paths. Union and record equality call Fable equality helpers, while their hash codes combine the structural hash of each field.

Possible solution 1: correct the existing helpers

Change safe_hash to prefer GetHashCode. Make structural_hash recurse through F# arrays and prefer F# hash methods, then let array_hash use the corrected structural helper. Union and record equality and hashing would call those helpers. Generated union case dataclasses would use eq=False so they inherit Fable's equality implementation, while retaining the __hash__ restoration added by #4805.

This is the smaller implementation. It would also fix already-generated Python code when fable-library is upgraded. The drawback is that it changes the behavior of existing functions in the published Python runtime.

Possible solution 2: add new F#-semantic hash helpers

Keep safe_hash, structural_hash, and array_hash unchanged. Add new helpers, for example generic_hash and generic_array_hash, that prefer GetHashCode and recurse through structural values. Update Replacements.fs, unions, and records to call the new helpers. The union dataclass equality change is still required.

This avoids changing existing runtime entry points. The trade-off is that newly compiled output requires the matching updated fable-library. Previously generated code continues to call the old helpers until it is recompiled.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions