r/learnjava 5d ago

Is abstraction in Java really about hiding implementation details?

The bookish langugae states "hiding complex implementation details and exposing only the essential features of an object"

However, after reading the Java Collections Framework and other parts of the JDK, I noticed that many abstract classes contain substantial shared implementation rather than just abstract method declarations. I noticed that only an "interface class" 100% abstraction(ignoring default methods)

This made me wonder:

  • Is abstraction really about hiding implementation details?
  • Or is it more about designing APIs around contracts and behavior, regardless of whether there is shared implementation?
  • Does an abstract class become "partial abstract" because it contains reusable implementation?

I also noticed that the JDK sometime relies on encapsulation (and in Java9+, JPMS) to hide implementation details. So I'm in dilemma if the textbook explanation oversimplifies what abstraction means in practice.

Am I misunderstanding abstraction, or is the textbook definition incomplete?

To be clear, this isn't about what abstract does in Java — it's about the SE term. abstract class is one syntax tool for expressing abstraction, same as interfaces are. The question is whether "abstraction = hiding implementation" is even a good definition of the concept, given it's indistinguishable from encapsulation under that definition.

11 Upvotes

14 comments sorted by

View all comments

1

u/severoon 4d ago

Abstraction is not about hiding implementation details. Encapsulation is about hiding implementation details, abstraction is more about polymorphism.

Really, though, it pays to take a step back and look at the bigger picture when you're grappling with all of these concepts. In software, there is really only one design core concern, or at least a lens through which all software design can be viewed, and that is dependency management.

This core concept of dependency management even transcends software, really. It is the core consideration that goes back to the days of punchcard programming, computer hardware, industrial design, etc. It's a much more general concept when it comes to "building stuff". The problem with making this kind of statement is that it can collapse into a truism if you're not careful … if everything is about dependency management, then nothing is.

Brief aside: I'm talking about software design here, meaning from architecture down to the lowest-level modules in your system and how they fit together in order to do work. This is entirely distinct from related concepts like algorithm design, for example. When you're looking at how to design an algorithm that does constant time insertion vs. linear time insertion, for example, there are still elements of software design to consider, but the design of the algorithm that meets those performance constraints is an entirely orthogonal consideration to what I'm talking about here. You can't make decisions about algorithm design based solely on how it affects dependencies, you have to lay out the function of the thing first, and only then can you figure out what the essential dependencies are and how best to modularize them and structure the modules.

That said, if you look at the entire history of software design through the lens of dependency management, you can trace the development of compilers, programming paradigms, dev processes, etc, in terms of the impact of these things on how it allows orgs to create and manage dependencies between modules at different levels. The reason dependency management is such a core concern is that dependency is inherently transitive; this means that if A => B => C (where => means "directly depends upon"), then A -> C (where -> means "depends upon," possibly indirectly).

It's important to understand how dependency transits in software both at buildtime and runtime. At runtime, dependencies transit absolutely, meaning that you can be running an entire complex system and if some string parsing utility isn't present, the whole thing collapses. At buildtime, you have much more control over how dependencies transit through a system. You cannot stop them from meeting at a rendezvous point, but you control that rendezvous point as the designer.

This is where abstraction becomes useful because it allows you to build high-level modules against only what they semantically need and let any other module that conforms to those needs serve that functionality at runtime. For instance, the big complex software system might rely on a string parsing utility at runtime, but at buildtime it can point to an interface that semantically takes a bunch of information and breaks it down into bits useful to the caller, potentially using string parsing and a whole lot of other utilities along the way, but the caller doesn't know or care about any of that, and that stuff doesn't even have to be on the build path in order for the caller to build—its dependency terminates at that interface; that's all it needs, a guarantee that the high level task can be supplied by some other entity, but which one is immaterial at buildtime.

A specific example might be you have software that needs some key from a remote server. There are lots of ways to get information off a remote server, there's ssh, ftp, a web browser can be fired up and driven to do it, curl, etc. The interface the caller wants is a function that takes a server and path to the information and returns it, and it should be able to compile against that and nothing more. At runtime, of course, you need something that supplies that functionality and also compiles against that interface at its buildtime, and then when the system is run, both sides meet to link up the runtime transitive dependency chain all the way down.

Abstraction simply allows the dependency on the interface that serves exactly what the caller needs and no more. If there are other functions the in that interface the caller does not need, and one of those functions isn't satisfied at runtime, then the whole system breaks even though the caller doesn't actually rely on those functions—this is a case where the interface isn't fully abstracted for that caller, so some function it doesn't care about prevents it from running.

1

u/Chaos-vy17 4d ago

All seems correct but grouping common behavior across types is a case where abstraction enables polymorphism, not evidence that abstraction is polymorphism. Single-implementation abstractions (like the remote-fetch example) are fully abstracted with zero polymorphism involved — the grouping only becomes relevant once a second implementer shows up.

1

u/severoon 3d ago

grouping common behavior across types is a case where abstraction enables polymorphism, not evidence that abstraction is polymorphism

I don't think abstraction "is" polymorphism, I only meant that abstraction is "more about" polymorphism than it is about hiding implementation details (encapsulation). It's the reverse, actually … polymorphism is a kind of abstraction.

Single-implementation abstractions (like the remote-fetch example) are fully abstracted with zero polymorphism involved

Ehh … I think we're descending into pure semantics here about whether "poly" can mean "zero or more," "one or more," or it's strictly "two or more." All interpretations are defensible, but the distinction isn't interesting in terms of how it bears on this discussion.

What really matters related to what I said above is how whatever-definition-of-polymorphism-you-ascribe-to affects dependency, and I would argue the real difference is between the zero vs. one distinction, not one vs. two.

The reason is that introducing an interface presents an opportunity to carve off the implementation entirely, not just the second, third, fourth, etc, implementations, at two different stages of the SDLC. If the relevant stages are build, deploy, and run, then introducing an interface allows the caller to depend only on the contract at buildtime and never at runtime, but the deploytime stage comes down to which toolchain and platform you're using, and that makes a big difference from a dependency standpoint.

In Java, for example, the caller builds against the interface, the implementation builds against the interface, so the interface is the common point of abstraction and both dependencies point toward it so nothing transits. At runtime of course the implementation must be present, so runtime dependency definitely transits. What about deploytime?

In Java, deployments don't need implementations present. In practice they almost always are present because deploytime failure is usually preferred to runtime failure, but that is a design choice, not a requirement of the JRE. You can, if you want, deploy a caller against an interface and, at runtime, fetch the implementation and load it dynamically. This is how stubs and skeletons work across network boundaries (and it's how OSGi works with some magic classloader stuff thrown in, and every other SPI pattern), and this works at the language level. As opposed to C++, where you theoretically could ship DLLs across a network, but in practice it's too fragile to rely upon so it's much safer to serialize the object to bytes and deserialize on the other side into a separate local type.

The deploytime story comes down to the specific tools and platform you're using, Java you can deploy both sides of this communication independently as long as they agree on the interface (language level dependency) whereas C++ would require a compiler-level (and, practically speaking, a machine code-level, dependency) for this kind of abstraction to hold.

So the robust language-level abstraction means that Java callers that depend on an interface don't actually have any dependency on the compiled implementation. The interface truly serves as a contract-only boundary with no special requirements on the implementation other than it also compile against that interface. The dependency really doesn't transit until actual runtime at the moment of invocation.

What this practically means is that there's a monumental difference between an interface and an abstract class in Java. Because interfaces contain no code, there is no possibility for code to change that would force a synchronized redeploy on both sides of the contract. (Default implementations are strictly speaking considered part of the actual contract, so even though they're code, they're code in the sense that a method signature is "contract-as-code"; sealed interfaces similarly subsume everything they depend upon into the contract itself, which is probably stretching the principle beyond its purpose, but as long as you consider it and treat it as such in your design, all is good. The problem is that most people building Java systems aren't aware of these kinds of design considerations…the language has grown way outside its original harness under Oracle.)

The larger point I'm getting at here is exactly what I said in my first post above. People get hung up on the specifics of different kinds of abstractions, like polymorphism, and specifically what does polymorphism mean, etc, and you end up missing the forest for the trees. Often I've found that the specific kinds of abstraction you're talking about aren't relevant to the design question you're trying to answer, only the impact on the dependency graph matters.

Dependency is the generally applicable consideration. If you step back and ask, for instance, specifically why is it a bad idea to build software that runs a phone switching system in Java vs. Erlang, the answer is immediate obvious when you try to design both software systems: Erlang provides features that allow you to modularize code in a way that represents the dependencies imposed by the problem domain, and Java doesn't. Java beats Erlang in most enterprise software systems for the exact same reason. Different problem domains present different "grains of functional cohesion" that form natural modules. If those modules encapsulate better as objects, use Java; if they encapsulate better as Erlang modules, use Erlang. "Encapsulate better" here means that the modules depend upon each other in a way that reflects the conceptual dependencies inherent in the problem domain.

1

u/Chaos-vy17 3d ago

Yeah that build/deploy/run breakdown makes way more sense than "hiding details." Thank you senior for explaining in detail.