But isn't the draw() method very much dependent on what the user interface is?
From a pragmatic sort of view, some code in your system needs to know how to draw something like a
Rectangle if that's a user-end requirement. And that's going to boil down at some point to doing really low-level things like rasterizing pixels or displaying something in a console.
The question to me from a coupling standpoint is who/what should depend on this type of information, and to what degree of detail (how abstract, e.g.)?
Abstracting Drawing/Rendering Capabilities
Because if the higher-level drawing code only depends on something very abstract, that abstraction might be able to work (through substitution of concrete implementations) on all the platforms you intend to target. As a contrived example, some very abstract
IDrawer interface might be capable of being implemented in both console and GUI APIs to do things like plot shapes (the console implementation might treat the console like some 80xN "image" with ASCII art). Of course that's a contrived example since that's usually not what you want to do is treat a console output like an image/frame buffer; typically most user-end needs call for more text-based interactions in consoles.
Another consideration is how easy is it to design a stable abstraction? Because it might be easy if all you're targeting is modern GUI APIs to abstract away the basic shape-drawing capabilities like plotting lines, rectangles, paths, text, things of this sort (just simple 2D rasterization of a limited set of primitives), with one abstract interface that can be easily implemented for them all through various subtypes with little cost. If you can design such an abstraction effectively and implement it on all target platforms, then I would say it's a much lesser evil, if even an evil at all, for a shape or GUI control or whatever to know how to draw itself using such an abstraction.
But say you're trying to abstract away the gory details that vary between a Playstation Portable, iPhone, an XBox One, and powerful gaming PC while your needs are to utilize the most cutting edge real-time 3D rendering/shading techniques on each one. In that case trying to come up with one abstract interface to abstract away the rendering details when the underlying hardware capabilities and APIs vary so wildly is almost certain to result in enormous time designing and re-designing, a high probability of recurring design changes with unanticipated discoveries, and likewise a lowest-common denominator solution that fails to exploit the full uniqueness and power of the underlying hardware.
Making Dependencies Flow Towards Stable, "Easy" Designs
In my field I'm in that last scenario. We target lots of different hardware with radically different underlying capabilities and APIs, and to try to come up with one rendering/drawing abstraction to rule them all is borderline hopeless (we might become world-famous just doing that effectively as it would be a game changer in the industry). So the last thing I want in my case is like the analogical
Particle Emitter that knows how to draw itself, even if it is expressing that drawing in the highest-level and most abstract way possible...
... because those abstractions are too hard to design correctly, and when a design is hard to get correct, and everything depends on it, that's a recipe for the most costly central design changes that ripple and break everything depending on it. So the last thing you want is for the dependencies in your systems to flow towards abstract designs too hard to get correct (too hard to stabilize without intrusive changes).
Difficult Depends on Easy, Not Easy Depends on Difficult
So what we do instead is make the dependencies flow towards things that are easy to design. It's much easier to design an abstract "Model" which is just focused on storing things like polygons and materials and get that design correct than it is to design an abstract "Renderer" which can effectively be implemented (through substitutable concrete subtypes) to service drawing requests uniformly for hardware as disparate as a PSP from a PC.
So we invert the dependencies away from the things that are difficult to design. Instead of making abstract models know how to draw themselves to an abstract renderer design that they all depend on (and break in their implementations if that design changes), we instead have an abstract renderer that knows how to draw every abstract object in our scene (models, particle emitters, etc), and so we can then implement an OpenGL renderer subtype for PCs like
RendererGl, another for PSPs like
RendererPsp, another for mobile phones, etc. In that case the dependencies are flowing towards stable designs, easy to get correct, from renderer to various types of entities (models, particles, textures, etc) in our scene, not the other way around.
- I am using "stability/instability" in a slightly different sense from Uncle Bob's afferent/efferent couplings metric which is measuring more the difficulty of change as far as I can understand. I am talking more about "probability of requiring change", though his stability metric is useful there. When "probability of change" is proportional to "ease of change" (ex: the things most likely to require changes have the highest instability and afferent couplings from Uncle Bob's metric), then any such probable changes are cheap and non-intrusive to make, requiring only replacing an implementation without touching any central designs.
If you find yourself trying to abstract away something at a central level of your codebase and it's just too difficult to design, instead of stubbornly beating heads against walls and constantly making intrusive changes to it each month/year that requires updating 8,000 source files because it's breaking everything that depends on it, my number one suggestion is to consider inverting the dependencies. See if you can write the code in a way such that the thing which is so hard to design is depending on everything else that's easier to design, not having the things that are easier to design depending on the thing that's so hard to design. Note that I am talking about designs (specifically interface designs) and not implementations: sometimes things are easy to design and hard to implement, and sometimes things are hard to design but easy to implement. Dependencies flow towards designs, so the focus should only be on how difficult something is to design here to determine the direction in which dependencies flow.
Single Responsibility Principle
To me SRP is not so interesting here usually (though depending on the context). I mean there's a tightrope balancing act in designing things that are clear in purpose and maintainable but your
Shape objects might have to expose more detailed information if they don't know how to draw themselves, for example, and there might not be much meaningful things to do with a shape in a particular use context than construct it and draw it. There's trade-offs with just about everything, and it's not related to SRP that can make things aware of how to draw themselves capable of becoming such a maintenance nightmare in my experience in certain contexts.
It has much more to do with coupling and the direction in which dependencies flow in your system. If you're trying to port an abstract rendering interface that everything depends upon (because they're using it to draw themselves) to a new target API/hardware and realize you have to considerably change its design to make it work effectively there, then that's a very costly change to make which requires replacing implementations of everything in your system that knows how to draw themselves. And that's the most practical maintenance issue I encounter with things aware of how to draw themselves if that translates to a boatload of dependencies flowing towards abstractions which are too difficult to design correctly upfront.
I mention this one point because, in my experience, this is often the biggest obstacle to flowing the direction of dependencies towards things easier to design. It's very easy for developers to get a bit ambitious here and say, "I'm gonna design the cross-platform rendering abstraction to rule them all, I'm gonna solve what other developers spend months in porting, and I'm gonna get it right and it's gonna work like magic on every single platform we support and utilize the state-of-the-art rendering techniques on every one; I already envisioned it in my head." In which case they resist the practical solution which is to avoid doing that and just flip the direction of the dependencies and translate what might be enormously costly and recurring central design changes to simply cheap and local recurring changes to implementation. There needs to be some sort of "white flag" instinct in developers to give up when something is too hard to design to such an abstract level and reconsider their whole strategy, otherwise they're in for a lot of grief and pain. I would suggest to transfer such ambitions and fighting spirit to state-of-the-art implementations of an easier thing to design than to take such world-conquering ambitions to the interface design level.