Blog>>Software development>>Frontend>>Decoding inheritance: an insight into its use and misuse

Decoding inheritance: an insight into its use and misuse

Nowadays, there's a notable emphasis on cultivating positivity and minimizing criticism. While this approach has its merits, particularly in fostering a more supportive environment, it's important to recognize the unique value of constructive critique in the field of engineering. Learning from setbacks, malfunctioning systems, and user-unfriendly products is crucial for advancement. 

This article aims to delve into this often-overlooked aspect of engineering. Drawing from my own experiences, I will highlight one of the suboptimal practices I have encountered, which is inheritance. I will analyze its pitfalls, and offer practical strategies for circumventing this particular common error in the engineering process.

Fundamentals of inheritance in object-oriented programming

In the realm of object-oriented programming (OOP), the practice of inheritance stands as a cornerstone concept, pivotal to the development and evolution of modern programming paradigms. The origins and implementation of inheritance trace back to the 1960s, aligning with the advent of Simula, widely recognized as the progenitor of OOP languages. Conceived by Norwegian computer scientists Ole-Johan Dahl and Kristen Nygaard, Simula introduced foundational OOP concepts such as classes, objects, and notably, inheritance.

Simula's genesis, primarily intended for simulation purposes, inspired its nomenclature. Within its design, Dahl and Nygaard innovatively incorporated inheritance to articulate a structured hierarchy of simulation scenarios. This approach enabled the formulation of more structured and modular code architectures. Inheritance in Simula allowed for the establishment of base classes encapsulating common functionalities, upon which derived classes could extend or modify these functionalities, thus fostering code reusability and a reduction in redundancy.

The pioneering introduction of inheritance in Simula paved the way for subsequent OOP languages, notably Smalltalk in the 1970s and 1980s, which further propagated the concept. This lineage extends to modern languages such as C++, Java, C#, Python, JavaScript, and TypeScript, each integrating inheritance as an integral feature.

export abstract class Animal {
  name: string;

  constructor(name:string) {
    this.name = name;
  }

  abstract speak(): string;
}

export class Cat extends Animal {
  speak(): string {
    return "Meow!";
  }
}

export class Dog extends Animal{
  speak(): string {
    return 'Woof!';
  }
}

const dog: Dog = new Dog('Buddy');
console.log(dog.speak());

const cat: Cat = new Cat('Whiskers');
console.log(cat.speak());

Inheritance advantages

At its core, inheritance enhances code structuring through the creation of class hierarchies, where a new class (termed a derived, subclass, or child class) inherits attributes and methods from an existing class (referred to as the base, superclass, or parent class). This mechanism offers multiple advantages:

  • Code reusability 

Inheritance fosters the reuse of existing code structures, minimizing redundancy and promoting efficient coding practices.

  • Extensibility 

It introduces a flexible framework, allowing for the seamless incorporation of new features and functionalities within the base class, which are automatically available to the derived classes.

  • Hierarchical organization 

Inheritance naturally establishes a hierarchy among classes, facilitating the representation of real-world relationships and promoting organized code development. For instance, within a base class named Animal, derived classes such as Dog or Cat can be created, each inheriting from Animal.

  • Polymorphism 

This concept enables polymorphic behavior, where derived classes can override or extend base class functionalities, thus allowing a single interface to represent various underlying data types.

  • Constructor inheritance

This includes the inheritance of constructors from the base class, subject to the specific rules and capabilities of the programming language in use.

Inheritance is a fundamental aspect of OOP, instrumental in the development of hierarchical, reusable, and adaptable code structures, significantly contributing to the advancement of software engineering and design.

Inheritance possible disadvantages or challenges


Inheritance, while a powerful feature of object-oriented programming, also has several disadvantages or challenges that need to be considered:

  • Tight coupling 

Inheritance creates a strong relationship between the parent and child classes. Changes in the parent class can directly affect all derived classes, which may lead to issues, especially if the system is large and complex. This tight coupling makes maintaining and updating code more challenging.

  • Increased complexity 

Overusing inheritance can lead to complex class hierarchies, making the code harder to understand and maintain. It might not always be clear which class methods are being used, leading to confusion and potential bugs.

  • Inappropriate usage 

Sometimes, inheritance is used inappropriately, for example, when classes are derived not because they represent an "is-a" relationship (which is ideal for inheritance), but because they share some common functionalities. In such cases, composition or interface implementation might be a better approach.

  • Difficulty in refactoring 

Due to the tight coupling between base and derived classes, refactoring a base class can be difficult and risky, as it might inadvertently break the derived classes.

  • Impedes encapsulation 

Inheritance can sometimes lead to a breach of encapsulation. Derived classes have access to the protected data of the base class, which can lead to unwanted side effects and makes it hard to guarantee the internal state of the object.

  • Rigidity

Inheritance can lead to a rigid structure. Once you define and implement a class hierarchy, changing it later can be complex and error-prone. This can limit the flexibility of an application.

  • Method overriding complexity

In cases where multiple methods are overridden, it can be difficult to track which method is actually being called at runtime, especially in deep class hierarchies. This can make debugging more challenging.

  • Potential for incorrect modeling 

There's a risk of incorrectly modeling real-world relationships using inheritance, leading to an inaccurate representation of the problem domain.

  • Subclass explosion

If too many subclasses are created to represent every possible combination of a base class's behavior, it can lead to an unmanageable number of classes in the system.

  • Fragmentation of implementation 

Sometimes, the implementation of a single functionality can be spread across multiple classes in the hierarchy, making it harder to see the "big picture" of the implementation.

While inheritance is a useful tool, it is important to use it judiciously. Understanding when to use inheritance versus composition, and keeping class hierarchies manageable and logical, are key to maintaining a clean and efficient codebase.

During my tenure as a freelance developer with a product company, I was tasked with assuming responsibility for the front-end aspect of a project, previously managed by another vendor. The technical cornerstone of this endeavor was Angular version 13, which, at the time, represented the latest iteration of the framework. The transition was facilitated by a comprehensive knowledge transfer from the previous team, laying seemingly smooth groundwork. However, it quickly became evident that the project would present considerable challenges.

The complexity arose primarily from the project's unconventional use of Angular's core classes. Rather than utilizing the classes provided by the Angular API directly, this project had implemented extensive subclassing. Key classes such as Component, Resolver, Service, HttpClient, and others were not used in their standard form. Instead, each had a custom subclass that modified its base functionality. This approach, while innovative, resulted in several of the aforementioned drawbacks of inheritance in software development.

Consider, for instance, the following nuances that raised questions about this implementation:

import { Injectable } from '@angular/core';
import {HttpClient, HttpHandler} from "@angular/common/http";

@Injectable({
  providedIn: 'root'
})
export class ApiService extends HttpClient{

  constructor(handler: HttpHandler) {
    super(handler);
  }

  // @ts-ignore
  override get(url: string): Promise<any>{
    return this.request('GET', url).toPromise();
  }
}
  • The application of inheritance: the project extensively used inheritance, but not necessarily to its advantage. This resulted in a deviation from the standard, ready-to-use functionalities as designed by the Angular framework. Consequently, this necessitated additional effort in reverse engineering and documentation, a significant deviation from standard practice.
  • Alteration of HttpClient behavior: a notable example was the transformation of the HttpClient's return type from an observable back to a promise. This change represented a regression from Angular's strategic shift in versions 2 to 4, where it transitioned from promises to observables, primarily to leverage the strengths of RxJS in handling asynchronous requests. Observables offer enhanced control, such as the ability to unsubscribe from long-running requests, an aspect crucial in modern web applications.
  • Impact on framework upgrades: Angular, known for its semi-annual update cycle and 12-month backward compatibility, is geared towards sustainable, long-term enterprise projects. The project's approach to inheritance posed significant challenges for leveraging Angular's automatic upgrade tools, potentially leading to increased debugging post-upgrade and, in extreme cases, necessitating a complete code refactor, thereby obstructing the update process.

The ramifications of this strategy were amplified when considering the broader team dynamics. With each major Angular class having its custom extension widely used throughout the system, the learning curve for new team members resembled that of assimilating an entirely new framework. This added complexity was not confined to technical aspects but extended to grasping the business context, internal processes, and communication protocols of the project.

In summary, while inheritance is a valuable pattern when applied judiciously, its implementation in this specific project context manifested several inherent disadvantages. It underscored the importance of balancing innovative coding practices with the need for maintainability, standardization, and team collaboration in complex software development environments.

A discerning approach to inheritance in software development

Inheritance, as a design pattern in software engineering, is not inherently detrimental. Its efficacy, however, hinges on the context of its application. Drawing from extensive professional experience, I advocate for a selective approach towards utilizing inheritance, especially in the realm of end product applications. A key recommendation is to refrain from extending classes that are integral to existing frameworks or libraries. This restraint is particularly pertinent in business applications, which often mirror the dynamic nature of market needs and thus benefit from more adaptable design patterns than inheritance.

The appropriate application of inheritance is more justified in the development of proprietary software intended to function as an API for end users (developers). In such scenarios, internal use of inheritance can be effective, provided that the overall design is meticulously considered. A prime example of this can be observed in Angular, where the Component class is an extension of the Directive class. Additionally, inheritance can be seamlessly integrated into projects that are fundamentally structured around object-oriented programming. In such cases, as inheritance aligns with the OOP paradigm, it contributes to the coherence and consistency of the codebase.

An often-underestimated aspect of software development is the strategic use of a minimal set of design patterns to resolve programming challenges. This approach aligns with the KISS (Keep It Simple, Stupid) principle, a guideline I hold in high regard. Overcomplicating a project with an excessive array of concepts and patterns contravenes this principle. Instead, simplicity and clarity should be the guiding tenets, ensuring that the chosen patterns are not only effective but also maintain the project's overall simplicity and understandability.

In conclusion, the judicious application of inheritance in software development requires a thorough understanding of the project's requirements, its architectural style, and the long-term implications of such design choices. By adhering to principles of simplicity and appropriate pattern selection, developers can harness the benefits of inheritance without succumbing to its potential pitfalls.

Alternatives to inheritance for enhanced code reusability

While inheritance is a longstanding paradigm in object-oriented programming for achieving code reusability, it's not without its limitations. In modern software development, several alternative patterns and principles are increasingly being embraced for their flexibility and maintainability. These alternatives not only address some of the challenges posed by inheritance but also open up new avenues for efficient code design.

Embracing composition

Composition is often heralded as a more versatile alternative to inheritance. It involves constructing complex entities by combining simpler ones, allowing developers to control and selectively include behaviors and properties. This approach is aligned with the principle of "composition over inheritance," advocating for object composition's suitability in fostering reusable and modular code.

Leveraging interface implementation 

In languages supporting interfaces, such as Java and TypeScript, polymorphism can be achieved through interfaces rather than traditional inheritance. By defining a contract (a set of methods) in an interface, various classes can implement this contract, thus allowing different objects to be used interchangeably. This method reduces tight coupling and enhances the modularity of the code.

Utilizing mixins for code injection 

A mixin is a special class offering methods to other classes without the need for inheritance. This pattern can be seen as a nuanced form of object composition and is particularly useful in environments that do not support multiple inheritance, providing a flexible way to extend the capabilities of a class.

Implementing the delegate or strategy pattern 

This pattern involves a class holding a reference to another class within a common interface, delegating certain tasks to this referenced class. It's akin to composition but with the added benefit of runtime behavior modification, making it a powerful tool for creating extensible and maintainable code.

Adopting the decorator pattern 

In scenarios where behavior needs to be added to objects either statically or dynamically, the decorator pattern shines. It allows for the extension of individual object behaviors without altering others from the same class, adhering to the open-closed principle, a crucial aspect of the SOLID principles in software design.

Factory pattern for object creation 

The factory pattern centralizes the creation of objects through a dedicated factory class. This encapsulation of the instantiation process allows for greater flexibility in modifying which classes are instantiated, promoting code reuse and decreasing dependencies.

Functional programming techniques

Languages that support functional programming paradigms, like JavaScript and Scala, offer a different approach to reusability. Through higher-order functions and function composition, functional programming emphasizes the use of functions as fundamental building blocks, providing a unique perspective on code reusability.

In my journey through software development, particularly with Angular, I've discovered the profound impact of certain design patterns that extend beyond traditional inheritance. These insights, grounded in personal experience, advocate for the use of dependency injection, inversion of control, and interface implementation to achieve more modular and maintainable code structures.

Embracing dependency injection and inversion of control: 

One of the key strategies I recommend is the use of dependency injection and inversion of control. Consider the following scenario: 

import {Injectable} from "@angular/core";
import {HttpClient} from "@angular/common/http";

@Injectable()
export class ApiService{
  constructor(private httpClient: HttpClient) {
  }

  get(url: string){
    return this.httpClient.get(url).pipe(
      //Do The required transformations here
    )
  }
}

In a setup where there's no inheritance, a class like ApiService becomes less rigid. The HttpClient is injected rather than being hardcoded, and the get method of ApiService is not technically an override of the HttpClient get method or any other.

However, it still allows for necessary modifications. For instance, if there's a need to convert the return type to a promise, this structure facilitates it. Moreover, this approach provides an additional advantage: should the need arise to replace HttpClient with another tool, the existing setup is already primed for such a transition, demonstrating the flexibility of this design.

Interface implementation in Non-DI scenarios: 

In scenarios where dependency injection isn't viable, which is a common occurrence, I suggest replacing inheritance with interface implementation. To illustrate, consider an object-oriented example involving an Animal interface. 

export interface Animal{
  name: string;
  speak: () => string
}

class Dog implements Animal{
  name;
  constructor(name: string) {
    this.name = name;
  }

  speak(){
    return 'Woof!';
  }
}

class Cat implements Animal{
  name;
  constructor(name: string) {
    this.name = name;
  }

  speak(){
    return 'Meow!';
  }
}

const dog = new Dog('Buddy');
console.log(dog.speak());

const cat = new Cat('Whiskers');
console.log(cat.speak());

This interface enforces the implementation of properties like name, and methods like speak. This approach is significantly lighter and less restrictive compared to inheritance, while still maintaining the desired behaviors in objects like dogs and cats.

Refactoring towards functional programming: 

Delving further, if we take the aforementioned example and refactor it using functional programming principles, the outcome is achieved with even less code. 

export interface Animal{
  name: string;
  speak: () => string
}

const dog: Animal = {
  name: 'Buddy',
  speak: () => 'Woof!'
}

const cat: Animal = {
  name: 'Whiskers',
  speak: () => 'Woof!'
}

console.log(dog.speak());
console.log(cat.speak());

This transformation underscores my belief that simplicity often leads to greater effectiveness, a principle that resonates not just in programming but in many aspects of problem-solving.

Services Frontend development

Summary

In conclusion, these alternatives to inheritance, encompassing dependency injection, inversion of control, and interface implementation, offer a diverse toolkit for developers. They cater to varying project requirements, programming languages, and system architectures. The choice among these patterns should be based on the specific needs of the project, striving for an optimal balance between code reusability, flexibility, and maintainability.

In the realm of software engineering, adopting such patterns can significantly enhance the quality and sustainability of code, aligning with the overarching goal of creating efficient, scalable, and easy-to-maintain software solutions.

Check out our frontend development services if you're looking for support in frontend-related issues.

Witek Michał

Michał Witek

Senior Frontend Engineer

Michał Witek is a senior frontend engineer and author on CodiLime's blog. Check out the author's articles on the blog. Read about author >

Read also

Get your project estimate

For businesses that need support in their software or network engineering projects, please fill in the form and we'll get back to you within one business day.