What Drives New Languages Like Rust and Go to Embrace Composition and Abandon Inheritance?
Java has a guideline: “Don’t use inheritance unless you have a good reason.” But Java doesn’t strictly limit inheritance — you can still use it freely. Go, however, is different: it only allows composition.
So why do modern languages promote composition? Actually, it’s not just new languages — even the veteran Java mentions in Item 16 of Effective Java: “Favor composition over inheritance.” Inheritance is a powerful way to reuse code, but it may not be the best method.
So it’s worth thinking this through carefully. Without diving into specific code, let's talk about the concepts. I believe we can explore this topic from the following perspectives:
- The characteristics of inheritance and composition
- What problems does inheritance cause in practical use?
- How does composition solve these problems?
Let’s examine a real-world example and progressively optimize our design by abandoning inheritance and embracing interfaces and composition. After reading this article, you will likely have a new understanding of both inheritance and composition.
Characteristics of Inheritance and Composition
We won’t dwell on the definitions here. Inheritance and composition are two common techniques for code reuse in object-oriented programming. They both enable reuse, but each comes with its own pros and cons. Let’s briefly discuss both.
Inheritance
Advantages:
- Code reuse: Properties and methods defined in the parent class can be directly used in the subclass.
- Extensibility via inheritance chains: Subclasses can inherit all properties and methods from their ancestors, improving scalability and maintainability.
- Both inheritance and composition can support polymorphism, where the same method behaves differently in different subclasses.
Disadvantages:
- Changes in the parent class affect subclasses: If the parent class changes, all its subclasses may need to be modified accordingly, increasing maintenance costs.
- Tight coupling: The subclass is tightly coupled to the parent class, reducing code flexibility and portability.
Now let’s look at composition. Compared to inheritance, composition has the following characteristics:
Composition
Advantages:
- Reduced coupling: Relationships between objects are loose; modifying one object doesn’t affect others.
- Flexible design: You can mix and match different objects as needed.
- Interface segregation: Composition enables separation of concerns, allowing different functional modules to be implemented independently, improving code reusability.
Disadvantages:
- Increased code volume: Compared to inheritance, composition may require more code to implement different combinations.
- More complex interactions: Under composition, object interactions may need more complex interface definitions and implementations, adding to the complexity.
What Problems Does Inheritance Cause in Practice, and How Can We Optimize It?
1. Initial Problems
Let’s say we want to design a class for a vehicle. Following object-oriented thinking, we abstract a general concept of a "vehicle" into a BaseCar
class, which has a default run()
behavior. All types of vehicles, like cars and trucks, can inherit from this abstract class.
public class BaseCar {
//... omitted other properties and methods...
public void run() { /*...*/ }
}
// Car
public class Car extends AbstractCar {
}
However, based on our understanding and requirements of the "vehicle" object, a vehicle should not only run, but also be able to repair its tires and engine. So the AbstractCar
becomes something like this:
public class BaseCar {
//... omitted other properties and methods...
public void run() { /* running... */ }
public void repaireTire() { /* repairing tire... */ }
public void repaireEngine() { /* repairing engine... */ }
}
Now, we need to implement a bicycle class. But a bicycle doesn’t have an engine — so how do we deal with that?
public class Bicycle extends BaseCar {
//... omitted other properties and methods...
public void repaireEngine() {
throw new UnSupportedMethodException("I don't have an engine!");
}
}
At first glance, the above logic seems to solve the problem, but in fact, it can lead to a giant mess. There are three major issues with this design:
First, if we keep adding all possible behaviors to the base class — for example, autonomous driving, panoramic sunroof, sunroof functionality — we’d have to pile everything into the base class. While this improves reuse, it also alters the functionality of every subclass, increasing complexity — something we want to avoid.
Second, exposing irrelevant functionality to unrelated objects (like engine repair for bicycles) is problematic. The bicycle class should not have to deal with engine repair methods at all.
Third, what about future extensibility? What if we want to model a person or an airplane, both of which can also “run” (in some sense)? This design doesn’t scale well and lacks flexibility.
So how do we address the issues above? You might have guessed it — interfaces. Interfaces are more focused on defining behavior, while abstract classes typically define common base behavior for a type. The use of abstract classes here has actually increased the complexity.
2. Optimization Using Interfaces
To solve the problems above, let’s ignore specific objects and instead focus purely on behaviors: running, repairing the engine, and repairing tires. We can define these behaviors as interfaces: IRun
, IEngine
, and ITire
.
public interface IRun {
void run();
}
public interface IEngine {
void repaireEngine();
}
public interface ITire {
void repaireTire();
}
Now, when implementing the Car
class, we implement all three interfaces: IRun
, IEngine
, and ITire
. For a Bicycle
, we only implement IRun
and ITire
. For a Person
, we only need to implement IRun
.
public class Car implements IRun, IEngine, ITire {
//... omitted other properties and methods...
@Override
public void run() { /* running... */ }
@Override
public void repaireEngine() { /* repairing engine... */ }
@Override
public void repaireTire() { /* repairing tire... */ }
}
public class Bicycle implements IRun, ITire {
//... omitted other properties and methods...
@Override
public void run() { /* running... */ }
@Override
public void repaireTire() { /* repairing tire... */ }
}
public class Person implements IRun {
//... omitted other properties and methods...
@Override
public void run() { /* running... */ }
}
Doesn’t this make things much more flexible? By now, you should start to understand why modern languages like Go and Rust have abandoned inheritance and abstract classes, and instead kept interfaces for behavior abstraction.
However, there’s still one problem: it looks like every object still has to manually implement run()
, repaireEngine()
, repaireTire()
, and so on. Isn’t that a pain? What about code reuse?
Hold on — composition is about to take the stage.
3. Optimization Using Composition
To solve the issue mentioned above, we can first implement the interfaces, then use composition and delegation to achieve reuse. Here’s how the code might look:
public class CarRunEnable implements IRun {
@Override
public void run() { /* car runs... */ }
}
public class PersonRunEnable implements IRun {
@Override
public void run() { /* person runs... */ }
}
// Other implementations omitted: EngineEnable / TireEnable
Then we define our object classes using composition — each class includes behavior classes as fields, and delegates the interface method calls to those fields.
public class Car implements IRun, IEngine, ITire {
private CarRunEnable runEnable = new CarRunEnable(); // composition
private EngineEnable engineEnable = new EngineEnable(); // composition
private TireEnable tireEnable = new TireEnable(); // composition
//... omitted other properties and methods...
@Override
public void run() {
runEnable.run();
}
@Override
public void repaireEngine() {
engineEnable.repaireEngine();
}
@Override
public void repaireTire() {
tireEnable.repaireTire();
}
}
Let’s look at the Bicycle
and Person
classes:
public class Bicycle implements IRun, ITire {
private CarRunEnable runEnable = new CarRunEnable(); // composition
private TireEnable tireEnable = new TireEnable(); // composition
//... omitted other properties and methods...
@Override
public void run() {
runEnable.run();
}
@Override
public void repaireTire() {
tireEnable.repaireTire();
}
}
public class Person implements IRun {
private PersonRunEnable runEnable = new PersonRunEnable(); // composition
//... omitted other properties and methods...
@Override
public void run() {
runEnable.run();
}
}
Now looking at the above code, doesn’t the logic feel much more clean and satisfying?
Want to add new features? Go ahead — it won’t affect other classes.
The coupling has been significantly reduced, and the cohesion isn’t any worse than with inheritance. This is what people refer to as high cohesion, low coupling.
The only drawback is that the total code volume has increased.
So let’s return to the original question:
What causes new languages like Rust and Go to embrace composition and abandon inheritance?
By now, you likely have your answer.
We are Leapcell, your top choice for hosting Rust projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ