Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
The bigger the interface, the weaker the abstraction (functionallyimperative.com)
97 points by zdw on July 29, 2023 | hide | past | favorite | 59 comments


I like Eric Meijer's take on this. He said that the important property of interface are not the actual method signatures, but rather laws that the methods follow when being called.

I think it follows that with bigger interface, there are more laws to follow (for example, read() must be called after open() but not after close()), and as a consequence, utility of that abstraction gets more limited.


With the corollary that the laws are complete.

YAGNI takes a back seat to algebraic closure.


This runs the risk of being reductionist to the point of useless. Is akin to the idea of "do one thing," or "single responsibility."

The danger is these all fail to grow. Or, rather, for them to work, you have to force the growth on either side.

Consider, you are using a writer to write data. How much can you write before it has to slow down? How do you signal broken data connections? How do you resume a previously stopped writer? How do you stop a writer?

Most of these do not have universal answers. But it gets worse. The main abstraction there was the symbolic byte. How do you convert your code's data to bytes? Is that stable? As in not changing.

So, you can have choke points of small interfaces to connect things. But to make that work, you have a lot to do on both sides.


The principal is correct: "the bigger the interface, the weaker the abstraction."

But perhaps you feel there is an second implicit, prescription: "Your abstractions should be strong."

And that's not necessarily true. To every thing there is a season. Excessive abstraction is astronaut architecture.

---

The principal is a good one.

The bigger the interface, the weaker the abstraction.

That's not to say big interfaces are bad or inappropriate. It's just to say they aren't strong abstractions, so don't expect them to be.


Totally fair. I am riffing off the idea that stronger is better. Weaker is worse. Fairly common framing, I thought

But in the spirit, how is a single method "stronger?" Usable in more places, maybe? Often hides too much, such that it really can't be used everywhere.

Describes more things? Well, yeah? But not in meaningful way. At this level of "abstraction," a car is indistinguishable from a bike. Certainly fair for some contexts. Not many relevant ones, though.

And it is that last, that I think sticks. What is the most you can stick to an abstract set of capabilities? That will give you the most utility.

It does mean the cost of implementation raises. But that is realistic?


It's principle, not principal.


The standard solution to this problem is a so called "interface smuggling".

You can implement more advanced interface types of the initial interface does not provide the required abstraction, and then do type switch at runtime and gradually switch your code to use the newer interface.

The name of this technique is from https://utcc.utoronto.ca/~cks/space/blog/programming/GoInter...


Doesn't that frequently run into other code that well-intentionedly wraps a value of the original interface adding new functionality but not passing through the newer interface, because it simply can't account for all interface smuggling that might be going on? Does sophisticated Go code just not use decorator types like I've run into?


A bit like duck typing.


its a misconception that single responsibility is about doing one thing. in fact the principle allows for a system to do many things, but the idea is that it should only serve a single actor.


Calling it a misconception is not really fair.

The "serve a single actor" is a recent revision of the principle by Uncle Bob, from a 2018 book, AFAIK. That's almost 20 years too late (if going by the sources I can find).

I can't find mentions of "actors" or similar terms in Agile Software Development or Clean Code. Pretty much every pre-2018 literature says that SRP relates to modules "[having a] single reason to change", or "[not having] too many responsibilities". The examples are always fuzzy and imprecise, but in the books the principle essentially boils down to class/method size. There are no examples in Clean Code or ASD relating to this new meaning, nor mentions of "actors" or "stakeholders", and until recently there's rarely any article contradicting the original meaning of SRP.

This revisionism seems to come first from backlash from part of the programming community, and also an adaptation of the more recent "one team per microservice" rule.

But the damage of almost 20 years of SRP being taught the way it was in the past was already done.


From Wikipedia, Uncle Bob made a book in 2017 and one in 2019.

Do you know which one you're referring to?

2017. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall. ISBN 978-0134494166.

2019. Clean Agile: Back to Basics. Prentice Hall. ISBN 978-0135781869.


It's the 2017 one, thanks for the correction.


I assert it is misguided to cling to any view of these as a fundamental truth. They are modeling tools. As such, use them for their usefulness. Don't chase truths with them, at they are tools, not truths.


i agree, and its important that we understand what the tools were designed to do before we use them


Slightly OT: I like Go, but the "-er" convention, while useful, has always bothered me because of the following inconsistency, well illustrated by two examples from the article:

    Writers write.
    Readers read.

    type Reader interface {
     Read(p []byte) (n int, err error)
    }

    type Writer interface {
     Write(p []byte) (n int, err error)
    }
In a Go interface, a reader doesn't read: a reader gets read from. The reader (in the English language sense) is the client code of the Reader interface (in the Go sense).

That said, I find the Java "-able" convention even uglier, though it answers my complaint.


Works if you consider the reader is what you use to do the reading. You have a source you want to read, so you get a reader that can do that.

Literally, you get a reader when you want to read something. You get a writer when you want to write something.

Though, beware thinking you can solve this with word choice.


> Works if you consider the reader is what you use to do the reading. You have a source you want to read, so you get a reader that can do that.

This strains ordinary language imo:

Say you have a "log" which implements the Reader interface. When I call "log.read()" surely we are reading from the log, no? The log itself isn't the reader. This matches everyday usage.

Perhaps your argument is that you can envisage the "log" object as an abstract "reader" of the bytes on disk, or something like that -- so the mental model is not a "log being read from" but instead "bytes on disk, an object able to read them (the Reader), and the code using that object to do the reading". In this worldview one never does anything oneself, but always uses a tool to do that thing -- the "-er" tool. I dislike this as it introduces an unnecessary layer of abstraction, but I grant it resolves the issue consistently.

Also in this conception now I have a variable named "log" which does not represent the log but "a reader of the log". So you have to either ignore that inconsistency or name the variable "logReader", which introduces extra verboseness everywhere.

The other resolution is simply to say this is a programming language, not ordinary language, and we decided that having a consistent, mnemonic way to name one method interfaces was more important than respecting natural language analogies. So f it, we're using "-er" to mean "-able".

I would be curious to know what the actual conversation was during the development of Go.


But odds are high your code isn't reading from any device. It is literally using a "reader of ___" to get the data read into a buffer that your code will process.

So, still seems to work for me.


Typically you are reading from a file, or the network.

    someFile.read(dest)
    network.read(dest)
I'm reading from a file or network in both cases. You can say you're using "the reader" to read, but as I said it a) introduces an unnecessary concept, b) is not how we naturally talk, and c) does not fit with the variable names (unless you want append "Reader" to all of them, which I think is ugly and bloated.)

It's not the worst thing in the world, and I understand the benefits of the choice, but still it slightly grates on me.


You are using some other code capable of reading from the file, though. Your code can do no such thing.

Consider now you send a physical letter to me? You use a mail carrier. You have my address, but sending is through the carrier. Same for calls, you use a service provider to make the call.


> Consider now you send a physical letter to me? You use a mail carrier. You have my address, but sending is through the carrier. Same for calls, you use a service provider to make the call.

We perform some actions with tools, but not all. Do you use a tool to run? To read a book? You simply run, or read. My issue is that Go forces every situation into the tool paradigm.

If I have an object representing a file that I can read from, I am reading from that file. I am not using anything to read from it. So either the object can no longer represent the file directly (but instead a tool used to read it), or the file can keep representing the file but the language is off because the file is now also a "Reader" (in the Go sense) of the file.

> You are using some other code capable of reading from the file, though.

We are talking about how to conceptually name pieces of our code to match our natural concepts and language. That is, the conversation we're having is one about modeling, so this move seems illegal to me. The "other code implementing reader" is not part of the model. It would be like arguing, in regular life, that "you can't say that you read a book, because really it is your brain doing the reading".


But you are playing into it? If you are the one running, you are a runner. If you used someone else to run a letter to the whatever, they were the runner.

It can fail if you are writing the physical contact point to the hardware, but very few of us do that. We use code that does that.

Do I use a tool to read? I have reading glasses. And an ebook reader. And I've delegated some reading to others. Life is complicated. Language no less so.


> And an ebook reader

Your ebook reader it the thing being read, not the thing that reads. Glasses for reading are also called readers, and they are the tool helping you read. Here, to your point, we see "language being complicated" -- the same convention being used for opposite things. But this is a tangential point leading us astray.

I thought about this some more in the shower, and I think I can distill my objection more clearly.

Methods on objects act on those objects. That is:

    object.do()
is equivalent, in English, to performing the action "do" on "object". To take a random example from Effective Go:

    d, err := f.Stat()
    if err != nil {
        f.Close()
        return err
    }
We "Stat()" the file (get statistics about it) and we "Close()" the file. This pattern is well-established in both Go and nearly every language with objects. Instance methods are actions performed on their object.

Now of course "File" also implements "Reader". Yet when I "f.Read()" the situation is exactly the same as in "Stat" and "Close". I am reading the file.

So where is the "Reader" now? Well the Reader is also the file. In the conceptual model, there is no other object around. The file is both the Reader and the object of reading. And that's my problem. The "reader as tool" is inconsistent with "methods act on objects".

In the tool model you've been arguing for, I would have to write something like:

    reader.read(file)
The redundancy of that aside, this is not typically what you see with Go code (though you sometimes do). You are more likely to see stuff like "f.Read()"

As I said, you can argue that Go's "File" object is not meant to represent a file, but instead an abstract tool: a thing which can Read and Stat and Close files.

But that's not what the documentation says: "File represents an open file descriptor."

That is, in the intended conceptual model, the "f" in "f.Read()" is a file. And we are reading it. And that is at odds with it being a "Reader" -- a thing that reads.


If I ask you to tell me how Moby Dick starts, you may open an ebook reader, point it to a Moby Dick ebook which it will read for you and display as text on a screen, then you will read that text out loud. To me, you are then my Moby Dick reader. I have not read anything, I used a reader to give me a piece of Moby Dick.

The model is similar with Go's interfaces. You can use a FileReader to read from a file on disk and give you the data. You didn't read the data yourself, the Reader read it for you and put it in a []byte.

Interfaces are always named for what purpose they serve to others, not for what they do internally.

I do agree that the File name is misleading for what os.Open actually returns. Java's FileStream name seems to capture the concept more clearly to me. However, I think this is am intentional choice on the part of Go designers, who are somewhat allergic to object-oriented design. This is the same reason they don't want the receiver of a method to be named "this", even though that is exactly what the code models.

Basically, the Go designers want to pretend that Go struts are not objects, they are only data, and that methods on that data are kinda just free-floating functions with some extra syntax sugar. Of course, that is not what Go actually does - a Go struct instance with methods and private fields is exactly equivalent to a Java Object instance and completely different from a C struct instance. But they don't like that framing.

You'll see this all over the Go stdlib - struct are named after the data they represent, but then they also implement interfaces named for the purpose they serve to others.

So what is an instance of the struct named File? Go has chosen a dualistic perspective, like with the particle/wave dualism in physics. In some experiments, a File is just a representation of an open file, that you pass to other methods to do something with (e.g. ioutil.ReadFile(file)). In other experiments, it is an object which can read data from the OS and return it to you (e.g. file.Read(), or ioutil.ReadAll()).


I think this is an excellent summary and I agree with all of it.

> Go has chosen a dualistic perspective, like with the particle/wave dualism in physics.

This summarizes my complaint. It's not consistent. It forces me to switch between perspectives and keep two contradictory models in my head. As I said, it's not the worst thing -- I can deal with it. But I consider it a kind of conceptual wart in the design, or at least of the naming conventions and idioms.

A final pedantic point. I find your interpretation of "ebook reader" interesting, and grant that it is a valid one, and fits with the Go "thing as tool" perspective. However, I don't believe it is the perspective most people use in everyday life, and I think the dictionary definition makes this clear:

e-reader (noun) -- a portable electronic device used for reading books and other text materials that are in digital form.

That is, and e-reader is the thing, the object, the book-substitute that you read.

From: https://www.dictionary.com/browse/ebook%20reader

EDIT: on 2nd thought, maybe that definition does back up your interpretation. "device used for reading books..." I still feel like in practice it is modeled in people's minds just like a "different kind of book" but maybe that is just me, or just some people....

EDIT 2:

> In other experiments, it is an object which can read data from the OS and return it to you (e.g. file.Read()...)

My other objection here (as I pointed out elsewhere) is that if I am supposed to take the "interface as tool" perspective when interpreting the "Reader.Read()" method, then the variable "file" is misnamed. I read a file, I don't use a file as a reader to read a file.

Since I can only name a variable according to one perspective, the name will always feel wrong when I switch to the other.


The reader reads the file into a string. You then access or modify the string. The file is a thing on disk being read by a file reader, an implementation of the reader interface, which reads the file.


In this case, isn't it kindof right though? If it says it represents a file descriptor, not a concrete file. The descriptor reads the file for you. You close the descriptor, not the file, etc. Though in that case you could argue the name of the object should be FileDescriptor and not File. You are onto something, often we model functions to act on objects, not objects to be tools acting on other objects.


In typical OS definitions, a file descriptor or file handle doesn't do anything, it's just some abstract way of referring to a position in a file uniquely. You can ask the OS to read data from the position in the file that the file descriptor points to, or to write data to it, or to map a segment of it into your memory, or to tell you other details about the file. But the file reader, and the file writer, and the file mapper etc are all parts of the OS, not the file nor the file descriptor.

In this sense, the GP is right - to respect OS terminology, in Go we should write

  fileReaderWriter := os.Open(path, "rw")
Since os.Open doesn't return a file handle/descriptor, but an object capable of reading or writing to a file. This is different from C, where open() does return a FILE*, which is just a handle to a file which you explicitly pass to read() or write() or mmap() etc.


Here's some example C using one:

    bytesRead = read(fd, buf, BUF_SIZE - 1)
You read the from the file descriptor into the buffer. Just like in Go you read from a file into a byte array. Neither the file descriptor nor the file is conceptualized as a tool for reading. The file descriptor is merely an abstraction of the file, which extends the concept of "file" to include pipes, sockets, and other io.


A diagram that made things click for me is putting different abstractions on a grid showing the different combinations of high level contracts they fulfil.

Every combination of push/pull, read/write, and sync/async can exist and makes sense.

The Go interface is “sync pull read” which is “Readable”.

Then “Reader” is “sync push read”, etc…

Another axis is “single item”, “sequence of items”, or “contiguous array chunks of items”. These are “property”, “iterator”, and “stream” respectively of synchronous. They’re “task”, “asynchronous iterator”, and “async stream” if non-blocking. Etc…


I've always suspected that 'Reader' and 'Writer' were names borrowed from Oberon since that's one of the influences that contributed to early Go design:

https://github.com/Project-Oberon/Source-Code/blob/cf7f6a6cd...

Please enjoy this microdose of historical trivia even though it doesn't answer the question of why those names were chosen (when designing Oberon).


In defense of Go “er” structure. It’s written this way because you, as the caller, are the reader. It’s almost an Actor pattern, almost… but it definitely takes the side of “when using this interface, what am I?” mentality. Once you realize the API’s were designed for you, the consumer, and not the provider, you appreciate it much more.


Why not use both? -er’s do stuff to things while -able’s get stuff done to them and use them as it make sense.

Use the one that makes the most sense given the situation.


You could, but this is not Go's convention. Go's convention is that when you have an interface with a single method "x", you name the interface "xer".

This has the advantage of being simple and mnemonic. It has the downside I already mentioned.


> In a Go interface, a reader doesn't read: a reader gets read from.

It's both. The Reader is reading from an underlying type.


Java has Reader and Writer as well, and I agree with your critique of that naming.


The Reader is doing the reading.

  reader.Read(p); // reader, please read into this array
The caller is obtaining the result of the read operation.

---

What mows a lawn? A person, or machinery?

In English, either can be referred to as a "lawn mover." However, most often "lawn mower" has the most direct meaning: the machine.


See my reply here for the problems with that: https://news.ycombinator.com/item?id=36926392

EDIT:

To elaborate my reply there tailored to your lawn mower example...

> What mows a lawn? A person, or machinery?

Yes, in this case the tool analogy matches real life (and your interpretation of Go interfaces as tools): I use a lawn mower to mow the lawn.

My issue is that there isn't always a tool, but the Go worldview forces there to be one:

What reads a book? A person, or a ...?


I have this analogy stuck with me: «Classes should be deep, not large». Which means: imagine a thin rectangle, but very tall, which stands on its thin side up; now, the X-dimension is the price you have to pay upfront to understand an abstraction ("interface") and Y-dimension is what you get once you understand it. The ratio between the two is the "leverage" you get from that abstraction.

Now imagine a perfect square: you gain as much "leverage" with your abstraction as you get functionality out of it: this the prime example of your dumb setter in Java, because it takes you exactly the same effort to learn what it's doing, as for you to do it yourself.

Again this is a picturesque analogy.


I don’t know if that book originated the idea, but your description fits closely to Philosophy of Software Design.


Similarly, the leverage or “reusability” equals complexity of the implementation divided by size of the interface


> So you list all the things that the ideal version of yourself must have and a long list of things you must do. But to make any progress on your goal, you have the cognitive load of keeping track of all the requirements you’ve put on yourself. I speculate that this is why many New Year’s resolutions fail; the abstraction for building your future self is weak.

Or people just pop them off their stack willy-nilly because it’s a vague tradition to do so. Because most people who are old enough to be serious about a “New Year’s resolution” also are old enough to understand that if you really want to accomplish something or change something, it takes time and dedication and not just pinky-swearing at midnight over champagne.

Also, not to nitpick, but the reason people make lists of things to get done is so that there is literally no cognitive load in keeping track of them. And it’s a weird pivot to go from people have a lot of things they think they want to accomplish to writers write and runners run (literally cases where people have only one thing they want to accomplish).

The framing of this piece was so weird.


This lesson is applicable to lots of languages, not just Go. For example, looking at Swift (where the equivalent of an interface is a protocol), we find many protocols in the standard library that either have one function requirement, or add one function requirement to a parent protocol:

- Equatable requires ==

- Comparable extends Equatable, requires <

- Hashable extends Equatable, requires hash(into:)

- Identifiable requires id

- Encodable requires encode(to:)

- Decodable requires init(from:)

- CustomStringConvertible requires description

- LosslessStringConvertible extends CustomStringConvertible, requires init?(_:)

- CustomDebugStringConvertible requires debugDescription

- CaseIterable requires allCases

- Sequence requires makeIterator()

- IteratorProtocol requires next()

- TextOutputStream requires write(_:)

- TextOutputStreamable requires write(to:)

- CustomReflectable requires customMirror

- CustomPlaygroundDisplayConvertible requires playgroundDescription

- AsyncSequence requires makeAsyncIterator()

- AsyncIteratorProtocol requires next()

And Apple’s Swift-only frameworks also have major single-function protocols:

- SwiftUI.View requires body

- SwiftUI.Animatable requires animatableData

- SwiftUI.TimelineSchedule requires entries(from:mode:)

- SwiftUI.EnvironmentKey requires defaultValue

- Combine.Publisher requires receive(subscriber:)

- Combine.Cancellable requires cancel()

- Combine.Subscription requires request(_:)


I sorta kind of agree with where the author is going, but I think that’s because I know Go and this works quite well in that language. But if I pretended to not know Go, this doesn’t make too much sense on its own, in an arbitrary context.

Even in Go, there isn’t such a strong correlation with interface size and “abstraction strength”. For instance, io.Reader and io.ReadWriteCloser are subjectively similar in terms of “strength”, it just depends on what you’re building. One of the “strongest” abstractions is net.Conn which has 8 methods, which is a lot. In fact, http.ResponseWriter was too small and an ongoing annoyance for years. Only recently was this flaw addressed with http.ResponseController.

Instead, I suspect that Go interfaces are powerful mostly because everyone and their grandma uses the same interfaces, thanks to (1) a std lib with opinionated interface definitions and (2) that the language itself is very focused on “one way” of representing things (no function coloring, for instance). This allows for eg a vast ecosystem of http middleware, that isn’t tied to a specific http server.

Indeed, whenever you venture outside of std interfaces, their appeal really starts waning. They’re fine, but the being seamlessly interoperable within the larger ecosystem is, in my view, where the magic is.


Go indeed has a simplicity and elegance that makes it really easy to write code which is highly performant and easy to maintain. That is the only reason I picked go when moving from common lisp.


> from common lisp

Is there such a thing as uncommon or rare lisp?


Common Lisp is the name of a programming language.


Possibly even Epic Lisp, Legendary Lisp, or Exotic Lisp.


Yes. In the long history of the Lisp family of languages, many dialects have been created, and most of them are uncommon and rare, becoming more if they are disused and recede into history.


I think "big" and "weak" are poor terms as they're vague. I think the real principle he's describing is something more like "context-free knowledge/rules are more flexible and useful".

The less context you have to consider when using an abstraction or making a decision, the simpler and more general it is. Like his example of "no drinking", there are no contexts in which he allows himself to drink, so it's simple and can deployed in any situation.

In the reader/writer interfaces, there is only a single requirement for reading or writing, the byte array.


I believe it's time to post this again: https://steve-yegge.blogspot.com/2006/03/execution-in-kingdo...

It's important to realize that for 90% of the software we write (all the stuff that's not frameworks) we do want weak abstractions. We want to bundle stuff exactly because abstractions create complexity and specificity is simple.


That's a weird take, abstractions don't create complexity, they simplify because it requires less context to understand what's going on.


I'm having a hard time understanding what the author means by interface given the context they're using it in at the start of the article. I remember not thinking I fully understood the term when used in programming either, or why you'd use one.

Can someone explain for the idiot?


I _think_ the idea is thinking of an "interface" as "something that you use as a way to interact with something from outside an abstraction". I'd summarize their argument as reasoning that if the goal of an abstraction is to avoid having to care about the internal details of something, an interface is a way to expose a subset of ways to interact with it, and the more you expand it, the more it exposes the internals of the thing being abstracted. I don't think they necessarily mean this only in terms of programming, but you could apply this argument to a programming language interface; if you use an interface for interacting with something instead of its direct functionality, each additional method you add to the interface exposes more details of the inner value, which makes it less of an abstraction.

Assuming my interpretation is correct, I'm not sure I totally buy this argument because there doesn't seem to be an obvious way to define the "size" of an interface where it holds true. The naive way to define the size would be number of methods, but I'd argue that methods can vary so much in terms of the amount of cognitive overhead they "expose" to the user that it's not very meaningful. Consider the Movfuscator compiler[0], which compiles code into binaries only using MOV x86 instructions because it happens to be Turing complete; as complex as it might be to learn x86 assembly as a whole and start writing programs directly in it, I'm dubious that trying to do so only with MOV would somehow be easier. Put another way, an x86 instruction set that only contains the MOV instruction is not a "stronger" abstraction than the actual one because it _introduces_ complexity that doesn't exist in the original. Does adding an ADD instruction alongside MOV increase the strength of the abstraction, or weaken it? I don't think there's an answer that we'd immediately all agree on for this sort of thing.

Ultimately, I think trying to measure interfaces through the number of methods they expose is similar to trying to measure code by the number of lines in it; while there are some extreme cases where we'd likely all agree (e.g. for a fizzbuzz implementation, having 10 lines of code is probably better than thousands of lines of code[1]), we can't really come up with a good objective metric because the "target" number is based on the complexity of what you're trying to define, and we don't have a way of quantifying that complexity. I think the ideas here are still super interesting though, not because they have definitive right or wrong answers, but because thinking about stuff like this overall improves one's ability to write good software for usage by other programmers.

[0]: https://github.com/xoreaxeaxeax/movfuscator [1]: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...


It's true that overly large interfaces can lead to increased coupling and decreased maintainability, and I appreciate the practical examples and tips shared to keep interfaces focused and robust.


So the DOM is a really weak abstraction?


Arguably, yes. You can only use the DOM in contexts where you need exactly that DOM.

That said, "weak" is a poor descriptor for this property.


I don't know what you're referring to. I don't think parsing an entire page of text, selecting an element, and returning it as an object you can manipulate represents a weak interface




Consider applying for YC's Winter 2026 batch! Applications are open till Nov 10

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: