←back to thread

Go subtleties

(harrisoncramer.me)
235 points darccio | 9 comments | | HN request time: 0s | source | bottom
Show context
rowanseymour ◴[] No.45666258[source]
Ah the old nil values boxed into non-nil interfaces. Even after 8 years writing go code almost every day this still bites me occasionally. I've never seen code that actually uses this. I understand why it is the way it is but I hate it.
replies(4): >>45666614 #>>45666792 #>>45666859 #>>45669163 #
aatd86 ◴[] No.45666792[source]
Yes, that'a bit too late after ten+ years perhaps but I wished we had a nil type and checking whether the interface is empty was a type assertion. In all other cases, like any(2) == 2, we compare the values.

Then again that would mean that the nil identifier would be coerced into a typed nil and we would check for the nilness of what is inside an interface in any(somepointer) == nil.

wrt the current behavior, it also makes sense to have a nil value that remains untyped. But in many other cases we do have that automatic inference/coercion, for instance when we set a pointer to nil.(p = nil)

That's quite subtle and that ship has sailed though.

replies(2): >>45666878 #>>45668625 #
1. kbolino ◴[] No.45668625[source]
> In all other cases, like any(2) == 2, we compare the values.

But any(nil) == nil returns true like you'd expect.

The reason that any((*int)(nil)) == nil is false is the same reason that any(uint(2)) == 2 is false: interfaces compare values and types.

replies(1): >>45668792 #
2. aatd86 ◴[] No.45668792[source]
that's another thing that makes it difficult to fix. Same thing here. 2 is an untyped constant so it should have returned true. (even if int is the default picked on short assignment)

any(uint(2)) == int(2) should return false indeed however.

replies(1): >>45669545 #
3. kbolino ◴[] No.45669545[source]
Untyped constants deserve an entry of their own in a list of the language's subtleties, that's for sure.

Importantly, untyped constants don't exist at runtime, and non-primitive types like interfaces aren't constants, so any(uint(2)) == 2 can't behave the way you want without some pretty significant changes to the language's semantics. Either untyped constants would have to get a runtime representation--and equality comparisons would have to introduce some heavyweight reflection--or else interfaces would have to be hoisted into the constant part of the language--which is quite tricky to get right--and then you just end up in a situation where any(uint(2)) == 2 works but x == 2 doesn't when x turns out to be any(uint(2)) at runtime.

replies(1): >>45670011 #
4. aatd86 ◴[] No.45670011{3}[source]
Not sure that reflection would be needed. They are exclusively on the RHS. But you're right. They would have a sort of type of their own instead of basically being int under the hood. type conversions do not require reflection. Or maybe you are thinking about something I have overlooked? In any case, not very likely a change anyway.
replies(1): >>45670884 #
5. kbolino ◴[] No.45670884{4}[source]
Let's assume the runtime representation case, as it's the most flexible. You'd need to do an assignability check to compare it to a typed number. Keep LHS as the interface, and RHS as the untyped constant.

That means following the type pointer of LHS, switching on its underlying type (with 15 valid possibilities [1]) or similar, and then casting either RHS to LHS's type, or LHS to the untyped representation, and finally doing the equality check. Something like this (modulo choice of representation and possible optimizations):

  import ("math/big"; "reflect")
  type untypedInt struct { i *big.Int }
  func (x untypedInt) equals(y any) bool {
    val := reflect.ValueOf(y)
    if val.Type() == reflect.TypeOf(x) {
      return x.i.Cmp(val.Interface().(untypedInt).i) == 0
    } else if val.CanInt() {
      if !x.i.IsInt64() { return false }
      return x.i.Int64() == val.Int()
    } else if val.CanUint() {
      if !x.i.IsUint64() { return false }
      return x.i.Uint64() == val.Uint()
    } else {
      var yf float64
      if val.CanFloat() {
        yf = val.Float()
      } else if val.CanComplex() {
        yc := val.Complex()
        if imag(yc) != 0 { return false }
        yf = real(yc)
      } else { return false }
      xf, acc := x.i.Float64()
      if acc != big.Exact { return false }
      return xf == yf
    }
  }
[1]: Untyped integer constants can be compared with any of uint8..uint64, int8..int64, int, uint, uintptr, float32, float64, complex64, or complex128
replies(1): >>45671471 #
6. aatd86 ◴[] No.45671471{5}[source]
If it is because of overflow, the idea was that there could be size classes at compile time. A bit like sub/supertyping but for numeric types. A simple type pointer check would be sufficient.
replies(1): >>45671725 #
7. kbolino ◴[] No.45671725{6}[source]
Size classes would save some space and speed up like-to-like comparisons, but wouldn't really do much for unlike comparisons (especially vs. float or complex). Looking only at type pointers fails to account for custom types (e.g., type Foo int); remember that an untyped integer constant can be compared with these. If you want the same semantics at runtime as you get at compile time, I don't see how you can get much simpler than what I wrote, in terms of the high-level logic. Though there are undoubtedly ways to optimize it, both because Go's compiler favors speed of compilation over efficiency of generated code, and because if this were the real code, it could poke at internals while my (probably working) example has to rely on the public reflect package, which is more abstract.
replies(1): >>45673035 #
8. aatd86 ◴[] No.45673035{7}[source]
But the LHS can determine how it compares to the RHS when the RHS is determined to be an untyped constant? Or instead of saying RHS (my mistake), let's say the typed side since comparisons are symmetric. A bit like having a special method attached to the type strictly for comparisons? That would be much less expensive than such a type switch if I am not mistaken. Would handle custom types as well. If promotable from the underlying type, that would not even bloat the executable. Unless I'm confused...
replies(1): >>45673527 #
9. kbolino ◴[] No.45673527{8}[source]
Ok, I think I follow. Instead of putting the comparison logic on the untyped side, you'd put it on the typed side. In code, reusing imports and untypedInt declaration, but replacing the method from before, you'd have:

  type intType interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 }
  func equals[I intType](x I, y any) bool {
    switch val := y.(type) {
    case I: return x == val
    case untypedInt: return val.i.IsInt64() && val.i.Int64() == int64(x)
    default: return false
    }
  }
And this would need a separate specialization for unsigned integers, floats, and complex numbers. This approach saves us from having to introspect the underlying type at runtime, but the example is incomplete. We also have float and complex untyped constants, so now each concrete type has to switch on all of the untyped constant forms it compares with. Still, it might be faster, though I'm not sure how much it reduces code bloat in practice (it's nice to not need the reflect package though).

[edit: side note, I was trying to actually write out all the code that would be needed, and I discovered that you can't call real or imag on generic types: https://github.com/golang/go/issues/50937]