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
Option
s.
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.