Hacker News new | ask | show | jobs
by g15jv2dp 700 days ago
So if I declare a couple of methods named "Foo" and "Bar", and someone somewhere declares an interface FooBar consisting of those two method names - and bad luck, same signature - then I have to make sure I follow the semantics of that interface I might not even know about, because even if I don't declare it so, anyone could use my class and push it through the FooBar-shaped hole. That's absurd.

Exercise: imagine what the semantics of the following signature are: `int Read(string)`. Did everyone get the same answer? And yet, with implicit interfaces, you absolutely need everyone to settle on the same answer. Otherwise, person A could write a class with such a method with answer A in mind, person B could write a library declaring an interface with such a method with answer B in mind, and person C could use the class from person's A code and the interface from person B's code without realizing.

10 comments

But parameters don't just fall into FooBar shaped holes. Someone passes them in there. Its still an explicit choice by the programmer to not read the docs and just roll the dice. Who thinks "I fits, I sits" is sufficient for a proper program anyway? Is it really a downside to allow this kind of munging?

Personally I want to see Extension Interfaces, so you opt into such a system in a slightly more explicit way. The slightly extra work aides in tooling and documentation but I can see how Go's way is not absurd.

That argument works pretty well for any bad api design (assuming sufficient documentation somewhere).

Yet I assume we can agree that regardless of how you can work around bad apis, good api design that prevents misuse is always better.

The safety measures have to stop somewhere of course (short of an api that is a single function which does exactly the thing you want without inputs or outputs, which seems unlikely), but extending type safety to interfaces does not seem like a step too far.

Another aspect of the issue is to consider the asymmetric nature of knowledge of the API author and the API consumer.

When one authors a type in a language like C# they must predict how that type will be used and what interfaces the author promises the type can be.

The API consumer might be unfamiliar with the types in an API at first, but ultimately they will know more about how they want to use the type and what additional contracts they think the type fulfills.

As it is, this knowledge is only useful when inheriting a type. There is no facility to "vouch" for a type in C#, currently. In Go structural typing fulfills this.

I think Extension Interfaces is the best of both worlds.

Exactly. Contracts are over shapes, types, behaviors, error-handling, the whole kit and kaboodle. Not just matching a call signature.
People could be shoving square pegs into coincidentally square holes but having coded in Go for years I haven't seen this scenario in practice ever. It would take a lack of understanding of the objects you're working with combined with a pretty big gap in your tests to cause a problem and at that point it's still not the most likely bug you'll see
I agree with you, but I can think of one counter example from Go. “crypto/rand.Rand” and “math/rand.Rand” both satisfy “io.Reader” which has lead to people inadvertently using the less secure choice.

That said, I think it is less about implicit interfaces and more about confusion between similar namespaces. After all “bytes.Buffer” also satisfies “io.Reader”, and I don’t see people confusing it with “crypto/rand.Rand”.

How does go handle marking up types with interfaces and then using types without that explicit interface?

Do devs use the explicit interfaces at all? Do they treat implicitly casted types with more scrutiny? Does the tooling care?

This is how Go works and there's really no issue... not sure what the gripe is here.
Yeah I was about to say, it's literally structural typing

https://wikipedia.org/wiki/Structural_type_system

There are plenty of issues. You simply can't write a method that e.g. accepts a read-only collection but does not accept a mutable collection. You can't use a "marker" interface with no methods/members to indicate intent. I mean sure you can write programs in it and they'll work most of the time, but if that's what you want you might as well use an untyped language.
Can you do that in C# now? Not with IReadOnlyCollection and the mutable collections implement IReadOnlyCollection.
You can accept e.g. IImmutableList. (Looking it up it seems like there isn't an IImmutableCollection, but you can see how the language makes it possible to implement one).
You flipped from looking for mutable to immutable and somewhat lost me.

How about we talk about the examples in the article. Stream and StreamReader? How should that be handled by making interfaces? You can extend those types but you can't apply new interfaces to the existing types.

> You flipped from looking for mutable to immutable and somewhat lost me.

If you want to write a function that takes immutable collections and does not accept mutable ones, that's generally impossible to do in a language with only structural typing. In a language like C# with nominal typing, you can have that function accept an interface that only immutable collection types implement, such as IImmutableList.

> How about we talk about the examples in the article. Stream and StreamReader?

I have no idea what they do or what they're meant for. It's certainly possible to define bad interfaces, I don't think anyone's denying that.

> You can extend those types but you can't apply new interfaces to the existing types.

Sure, that's a limitation and there are various ways to navigate that tradeoff (e.g. adapters, or a Haskell/Rust-style trait system where you can apply interfaces to existing types but you have to do so explicitly). My point is that structural typing is not a flawless approach that solves all your problems.

One shouldn't be mixing Stream and StreamReader. StreamReader is a subclass of TextReader that is used for reading strings. Stream works on bytes only.

Actually I think the point is kind of moot since the article claims these have identical Read() methods but that is not the case. One returns bytes and the other returns chars and so they have different signatures.

> That's absurd.

The whole point of an interface is to allow for multiple concrete implementations. What hidden requirements are you suggesting which would make the act of opting in to known working interfaces a good idea?

Separately, there's a bit of tension generally between authors wanting to limit promises made to callers and callers wanting to use any code that's good enough for their end application. Compile-time duck typing (implicit interfaces or structural typing or whatever) is a decent tradeoff for that. Something like a `fn ReadTwice(fn Read(string) int) fn (string) int` combinator almost doesn't care about your particular semantics, but that sort of generic code is impossible to write in a world with sealed classes, opt-in interfaces, and other sorts of features taking power away from callers (who have the appropriate context to know whether it's reasonable to use your code) and giving it to library authors (who want to support a narrow enough use case to guarantee their code is correct and tell everyone else to fuck off). Even just having two separate IReader interfaces or ISleeperClock or IClock or ITime or all the other sorts of permutations you might find in an ecosystem can cause major friction without actually adding any type safety.

> The whole point of an interface is to allow for multiple concrete implementations.

The issue is that in this case the second method may not be an implementation of the interface at all in the first place, simply a method that happens to have the same signature. That can happen easily when parameters are only built-in or BCL types.

Why does that matter though? Looking at the concrete `Read` example above, what semantics might apply to an IReader vs a class which happens to accidentally conform to the interface? If a person asked for an IReader argument and found your class instead, why would they be upset?
The problem here is one of perspective. Interfaces in Go belong to consumers not producers.

So sure, your “I used something that matches X but isn’t really X” is possible, but practically it doesn’t happen.

C++ concepts have the same problem. In practice is a non-issue for the language because templates already had this issue.

In old-school C++ you would probably handle this with traits. I.e. a specialization of a tag type that can be written by either the class author or a third party that indicates a types semantic compatibility with a concept.

Example: specialising std::hash instead of forcing everyone to add hashCode() members

And you can implement pseudo-nominal conformance to a concept Foo by requiring the presence of a 'yes_this_class_really_conforms_to_Foo' tag.
In my experience this really is not an issue. There’s an intentionality to using objects and you’re not a maddened fuzzer trying to plug random objects into arbitrary functions with no rhyme or reason.

It’s essentially a less likely version of using the wrong callback, something which has undoubtedly happened in the fullness of time but is of no real concern.

No in my opinion the issue is the opposite: implicit structural interfaces make it harder to discover what interfaces a type implements, and what you can do with it.

A secondary effect being that mismatches have worse reporting, whether you’re trying to implement an interface or the interface has changed from under you the compiler only reports use site so from there you have to did out what the type is and why it does not conform anymore, things get worse if side casts are involved. There’s actually a pattern for checking conformance:

    var _ Iface = (*Type)(nil)
Mmm yummy.

Oh yeah and if the interface removed a method and you didn’t realise you might be dragging that useless methods for a long while. Then again it’s not like your Java-style interface is any different.

> Oh yeah and if the interface removed a method and you didn’t realise you might be dragging that useless methods for a long while. Then again it’s not like your Java-style interface is any different.

In C# I usually use explicit interface implementations. (They're inconvenient to type, but Rider has a macro for it.) When the interface changes or disappears, my code won't compile.

Didn’t know that existed. It’s a bit of a half assed version of half of type classes but it’s an improvement that this is at least available.

Can you also implement interfaces on types you didn’t define?

I think a lot of people here are being confused by all the talk about "implicit interfaces" in the article. It's not actually talking about interfaces in C# at all, just about the old-fashioned way of passing callbacks.

If you consider that a problem, you would also have the same problem in any other language with first class functions. Someone might define a `readSomething(string -> int) -> ...` function. Does that mean everyone who now defines a `string -> int` function must make it suitable for `readSomething`? Obviously not. It's up to the caller to pass correct arguments to the functions they are calling.

There was a bug in the golang standard lib that was basically exactly due to that behavior. It seems that even the golang authors made such a mistake.
I can’t find it but IIRC the issue was mostly the internal use of reflection, possibly undocumented e.g. a function. Would take a reader, then do a thing, then close it if possible.

But it did not take a closeable reader, it’d just ask for a reader and close it from under you. And maybe this caught people who did not intend to implement a readcloser but it definitely caught people who just didn’t want their file closed because they had shit to do with it afterwards.

Your hypothetical issue is that you accidentally make a function conforming to an interface without actually meaning the same thing that the interface means?

That almost never happens in reality so it's not an issue.

For example, go has io.Reader which needs just `Read(p []byte) (n int, err error)`

Your issue is that you accidentally do Read with same signature, but it will mean something else, and the error it returns are different, so instead of EOF (as io reader should) it will return something else on end of file?

... it just doesn't happen in Go. You would need to go out of your way to break it. I guess it theoretically can happen.