..

Pointers, References, and Hashing: Rust != Go

For better and worse, I’ve grown accustomed to how much thinking the Rust compiler does on my behalf. Go, though, is more like a stern tutor: lots of opinions on how things should be done, but it will still let you fail.

I recently got bit by something obvious in hindsight that wouldn’t have been an issue in Rust, but which Go expects you to remain cognizant of.

Pointers + Orthogonality

I imagine much has been said about how Rust separates the concepts of:

  • Null values (Option<T>)
  • References (&)

Because I come from a SQL background, I’m comfortable in the realm of semantic nulls and treating references as an orthogonal concept related to managing memory.

However, Go uses a more historically conventional approach with pointers. This means that the intrinsic concept of nullable values (nil) is blended with the concept of references/pointers (*T).

As you likely know, Rust and Go then have very divergent behavior when it comes to equality and hashing.

Hashing null values

How I ran into this issue is my own foolish practice of trying to transliterate Rust (my preferred language) into Go (the environment I’m currently working in).

Say I’m handling an API request with nullable fields––I just model them as Options.

use std::collections::HashMap;

#[derive(Hash, PartialEq, Eq, Debug)]
struct CacheKey {
    endpoint: String,
    user_id: Option<u32>, // Some requests are authenticated, others aren't
}

fn rust_nullable_example() {
    let mut cache: HashMap<CacheKey, String> = HashMap::new();
    
    // Authenticated request
    let auth_key = CacheKey {
        endpoint: "/api/profile".into(),
        user_id: Some(123),
    };
    
    // Anonymous request  
    let anon_key = CacheKey {
        endpoint: "/api/profile".into(),
        user_id: None,
    };
    
    cache.insert(auth_key, "User profile data".into());
    cache.insert(anon_key, "Public profile data".into());
    
    // Lookups work perfectly - Option<T> hashes consistently
    let lookup_auth = CacheKey {
        endpoint: "/api/profile".into(),
        user_id: Some(123), // Same value, different memory location
    };
    
    let lookup_anon = CacheKey {
        endpoint: "/api/profile".to_string(),
        user_id: None,
    };
    
    assert!(cache.contains_key(&lookup_auth)); 
    assert!(cache.contains_key(&lookup_anon));
}

But by naively translating the user_id field as *int, we get behavior we hadn’t intended.

package main

import "fmt"

type CacheKey struct {
    Endpoint string
    UserID   *int // Nullable field - DANGEROUS in map keys!
}

func goNullableExample() {
    cache := make(map[CacheKey]string)
    
    userID := 123
    authKey := CacheKey{
        Endpoint: "/api/profile",
        UserID:   &userID, // Pointer to int
    }
    
    anonKey := CacheKey{
        Endpoint: "/api/profile",
        UserID:   nil, // No user ID
    }
    
    cache[authKey] = "User profile data"
    cache[anonKey] = "Public profile data"
    
    // Different address
    userIDCopy := 123
    lookupAuth := CacheKey{
        Endpoint: "/api/profile",
        UserID:   &userIDCopy, // Different pointer
    }
    
    lookupAnon := CacheKey{
        Endpoint: "/api/profile",
        UserID:   nil, // nil is consistent, at least
    }
    
    _, foundAuth := cache[lookupAuth]
    _, foundAnon := cache[lookupAnon]
    
    fmt.Printf("Found auth key: %t\n", foundAuth) // false - different pointer!
    fmt.Printf("Found anon key: %t\n", foundAnon) // true - nil is nil
    fmt.Printf("Cache has %d entries\n", len(cache)) // 2
}

When you see this, it’s like “OK–you got me. The UserID value isn’t the same; it’s an address and they’re different addresses.”

This was how I discovered Go uses location equality, while Rust uses content equality1.

Separation of Concerns: Nullability vs. Equality

The poorly thought out implementation above points to deep philosophical differences between Rust and Go.

Rust decouples the concepts of nullability and equality. When you want nullability, you use Option<T>. Option<T> uses T’s underlying equality semantics, which means you get content-based rather than location-based equality.

Even without looking at the standard library, I am confident the implementation of PartialEq for Option<T> is something like:

impl<T: PartialEq> PartialEq for Option<T> {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            // Defer to `T`'s implementation of `PartialEq`
            (Some(l), Some(r)) => r.eq(l),
            (None, None) => true,
            _ => false,
        }
    }
}

Go, on the other hand, continues a longstanding tradition of conflating these concerns by using pointers (*T) to represent both nullability and equality simultaneously, forcing you to choose between:

  • Content equality with no nullability (int)
  • Identity equality with nullability (*int)

You can’t easily have content equality with nullability without additional abstractions like sql.NullInt32 or custom wrapper types. In other words, there ought to be 4 choices here, but there are only 2. It feels to me––still sore from having been caught unawares––that this creates a semantic trap for developers.

This reflects each language’s design philosophy, at least as far as I experience it:

  • Rust provides a lot of guard rails to ensure developers don’t trip over small mistakes in production, though at the cost of being more verbose (.unwrap() is 9 times as long as *). You can also configure a type’s semantics to your liking (i.e., location-based equality is possible).
  • Go offers you one way of doing things and expects you to know the choice the designers made. Because there is only one way of doing things, there’s a sharp simplicity. Changing the “one way” of doing things is challenging.

Location-based equality in Rust

Curious about achieving Go’s semantics in Rust, I wanted to see how hard it would be to get Rust to offer location-based equality.

use std::collections::HashSet;
use std::hash::{Hash, Hasher};

// Wrapper that hashes pointer identity, not content
#[derive(PartialEq, Eq)]
struct IdentityPtr<T>(*const T);

impl<T> IdentityPtr<T> {
    fn new(reference: &T) -> Self {
        IdentityPtr(reference as *const T)
    }
}

impl<T> Hash for IdentityPtr<T> {
    fn hash<H: Hasher>(&self, state: &mut H) {
        // Hash the pointer address, not the content
        (self.0 as usize).hash(state);
    }
}

fn main() {
    let x = 6;
    let y = 6; // Same value, different address

    let mut value_set: HashSet<i32> = HashSet::new();
    let mut identity_set: HashSet<IdentityPtr<i32>> = HashSet::new();

    // These are the same value
    value_set.insert(x);
    value_set.insert(y);
    
    // These are different keys because they point to different addresses
    identity_set.insert(IdentityPtr::new(&x));
    identity_set.insert(IdentityPtr::new(&y));
    
    assert_eq!(value_set.len(), 1);
    assert_eq!(identity_set.len(), 2);
}

Memory and Perspective

The above implementation of location-based equality doesn’t strike me as particularly useful. In all of the Rust code I’ve written, I’ve never needed something like this.

But this exercise left me with a nagging question: why haven’t I ever needed location-based equality?

It’s hard to understand the absence of a need without experiencing the need in some other context (I’ve never wanted location-based equality in Go, either). And this is the default behavior of pointers in Go, so clearly the approach isn’t without merit.

The best answer I can come up with is this: Rust largely abstracts away the concepts of pointers, so the idea of “memory location” is not a first-class construct in the way it is in Go. Pivoting equality around such a concept would be bizarre.

However, Go’s approach is natural in its own context; in a garbage-collected language, aliases are common and pointers are first-class values you pass around freely. The language expects you to make conscientious decisions about the equality semantics you want—you might want location equality for performance, or to maintain relationships across contexts. Whenever you need content equality, you can engage it by using non-pointer values.

The challenge from Go’s perspective is when you want to treat nullable values as first-class entities.

What resonates with me is this: the tools you use don’t just constrain what you can express; they fundamentally shape how you approach problems in the first place. A true polyglot doesn’t rely on transliteration from their native language to others, but instead develops a deep understanding of each language’s concepts and idioms. To solve problems in Go, you need to shift your thinking to Go’s perspective.