Extension methods make code harder to read, actually

by: Ethan McCue

I apologize in advance for whatever comment sections form around this.

What are instance methods?

In many languages you can associate functions with a type.

class Dog {
    void bark() {
        System.out.println("Bark!");
    }
}

The name these are given differs on the language you are talking about and who you are talking to, but we'll go forward calling these "instance methods."

Instance methods are defined at the same time as the type is declared.

class Dog { // Type declared here
    void bark() { // Method declared within it
        System.out.println("Bark!");
    }
}

Instance methods can have access to fields or properties of the type they are associated with that might not be accessible to other code.

class Dog {
    private final String name;
    
    Dog(String name) {
        this.name = name;
    }
    
    void bark() {
        // name is accessible to this method, but not to outsiders
        if (name.equals("Scooby")) {
            System.out.println("Scooby-Dooby-Doo!");
        }
        else {
            System.out.println("Bark!");
        }
    }
}

And, in languages with the ability to "extend" types, instance methods might be overloaded by a subtype.

class Pomeranian extends Dog {
    @Override
    void bark() {
        System.out.println("bork.");
    }
}

Importantly instance methods are also "convenient" to call.

Most code editors can catch you after you've written the . after dog and offer an autocomplete list of "methods you might want to call."

void main() {
    var dog = new Dog("Scooby");
    // After "dog.b" you should be able to hit enter and
    // have "dog.bark()" filled in for you.
    dog.bark();
}

In addition to discovery, this is convenient for a practice known as "chaining." If one method returns an object which can itself have methods called on it you can "chain" another method call on the end.

void main() {
    String name = "  Scrappy   ";
    
    name = name
            .toLowerCase()
            .strip()
            .concat(" dappy doo");
    
    System.out.println(name);
}

This is widely considered to be aesthetically pleasing and will be the surprise villain of today's story.

What are extension methods?

If you are not the author of a type, but want to write functionality that builds upon the exposed methods and fields of one, you can write code of your own.

class DogUtils {
    private DogUtils() {}
    
    static void playFetch(Dog dog) {
        System.out.println("Throwing stick...");
        dog.bark();
        System.out.println("Stick retrieved.");
    }
}

Calling such a method will generally look different from calling an instance method.

void main() {
    var dog = new Dog("Scooby");
    DogUtils.playFetch(dog);
}

Importantly you need to know where to look for it (in this case that there is playFetch in DogUtils) and won't get that helpful autocomplete from writing dog.

Externally defined methods also don't play nicely with method chaining. Whenever you need to call them you probably need to "break the chain."

void main() {
    String name = "  SCRAPPY   ";
    
    name = name.toLowerCase();
    
    name = StringUtils.capitalizeFirstLetter(name);
    
    name = name
            .strip()
            .concat(" Dappy doo");
    
    System.out.println(name);
}

This is considered aesthetically displeasing.

Extension methods are a language feature that allow someone to make calling these externally defined methods look like calling an instance method.

// This is the "manifold" Java superset
// http://manifold.systems/docs.html
@Extension
class DogUtils {
    private DogUtils() {}
    
    static void playFetch(Dog dog) {
        System.out.println("Throwing stick...");
        dog.bark();
        System.out.println("Stick retrieved.");
    }
}
void main() {
    var dog = new Dog("Scooby");
    dog.playFetch(); // This turns into a call to DogUtils.playFetch
}

Upsides of extension methods

Because calling an extension method looks the same as calling an instance method, downstream users of a library can make a suboptimal API more tolerable by adding their own methods.

As an example, the Kotlin language uses its extension mechanism to "add methods" to java.lang.String that the Kotlin team would prefer existed.

This can make code more aesthetically pleasing and enables method chains to go unbroken, which in turn can make code easier to write.

void main() {
    String name = "  SCRAPPY   ";

    name = name
            .toLowerCase()
            .capitalizeFirstLetter()
            .strip()
            .concat(" Dappy doo");

    System.out.println(name);
}

This is often confused with making code easier to read.

Downsides of extension methods

1. They make life harder for library maintainers

Java added the .strip() method to String in Java 11. .trim() already existed but it isn't "unicode aware" and won't trim off everything we would consider to be whitespace.

As such, it would have been an ideal target for an extension method.

@Extension
final class StringUtils {
    private StringUtils() {}
    
    static String strip(String s) {
        // ...
    }
}

So if Java had extension methods there would have certainly been code that looks like this out in the world.

void main() {
    String catchphrase = "  zoinks  ";
    
    catchphrase = catchphrase.strip();
    
    System.out.println(catchphrase);
}

Where every call to .strip() was translated to a call to StringUtils.strip.

Now consider what happens when you go forward in time and the person writing String decides to add their own .strip() method.

If you recompile code that looks like the above does it

All of these options suck.

If it fails to compile now library authors need to consider how likely it is that adding a brand-new method is going to break downstream code. This is something that, in the absence of extension methods, is one of the few things that is basically a free action.

If it continues to use the extension method that can quickly become a code readability hazard. People form their own internal roladexes of what methods are available on certain types and what they do. If someone sees .strip() called on a String its not unreasonable for them to expect exactly the behavior of String#strip. If the semantics of the strip extension method differ from the semantics of the instance method...shit. Library maintainers need to care about this because any method they add that is likely to conflict with an existing extension method can trigger exactly this hazard.

If it switches to using the instance method now both library authors and library consumers need to be a lot more cautious when upgrading libraries. Code, as written, could change behavior from something as simple as adding a method. This is worse than failing to compile since at least if the compiler yells at you there is a sign that something is wrong.

2. They make code harder to read

Welcome to the part that was click-bait.

If the invocation of an instance method looks identical to invoking an extension method it is impossible to tell at a glance which is happening.

void main() {
    // Is this an extension method call or an instance method one?
    String name = "  Velma".stripLeading();
}

If the language automatically brings all extension methods "into scope" this problem is global to the entire codebase. If someone in some corner of the world adds an extension method that can alter the behavior of code or affect whether a particular line compiles.

If the language doesn't, that means you need some sort of import to make the extension methods available.

// If I hadn't been using this example the whole time, would
// you catch that "captializeFirstLetter" was the extension method?
@Extension(StringUtils.class)
class Main {
    void main() {
        String name = "  SCRAPPY   ";

        name = name
                .toLowerCase()
                .capitalizeFirstLetter()
                .strip()
                .concat(" Dappy doo");

        System.out.println(name);
    }
}

This is both a worse and similar situation to * imports. One line of code at the top of the file is needed for many other lines to be valid code, but there is no way to visually tie the two together.

import java.util.*;

void main() {
    var l = new ArrayList<String>();
}

The problem is that readability is about the ease of extracting information from text. Both * imports and any hypothetical design of extension methods make it harder to read code because they take information that could be written down and accessible and make it implicit.

That can be fine, sometimes. We're not in an anti-golf competition or anything. It is valid to trade readability for ease of writing. But we are lying to ourselves and/or others if we say that extension methods make code more readable.

What they do is make some code more aesthetically pleasing. Method chains are considered nice to look at. Beauty is just simply a different thing from comprehensibility.

3. They aren't that powerful, actually

There are more ways than extension methods to magically attach methods to types.

One of the ways that is popular in Scala is to use "implicits." Whenever you use a type in a context that it wouldn't otherwise work, Scala can implicitly wrap your type in another one that will make it work.

What does that mean? Well, if you had a line of code like this.

val name = "fred".capitalizeFirstLetter()

Then the Scala compiler will look for implicit conversions to a class that does have that method.

class EnrichedString(s: String) {
  def capitalizeFirstLetter: String = {
    Character.toUpperCase(s.charAt(0)) + s.substring(1, s.length())
  }
}

given Conversion[String, EnrichedString] with
  def apply(s: String): EnrichedString = EnrichedString(s)

val name = "fred".capitalizeFirstLetter

println(name)

This is more powerful since you aren't just able to magically add a method, you can magically implement an interface.

trait ThingDoer {
  def doThing: Unit
}

class EnrichedString(s: String) extends ThingDoer {
  def doThing: Unit = {
    println(s"Hello: ${s}")
  }
}

given Conversion[String, ThingDoer] with
  def apply(s: String): ThingDoer = EnrichedString(s)

val thingDoer: ThingDoer = "fred"

thingDoer.doThing

Are the rules for this confusing? Extremely.

Implicit conversions are applied in two situations:

  1. If an expression e is of type S, and S does not conform to the expression’s expected type T.
  2. In a selection e.m with e of type S, if the selector m does not denote a member of S (to support Scala-2-style extension methods).

In the first case, a conversion c is searched for, which is applicable to e and whose result type conforms to T.

Preach, sister.

Which is all to say that extension methods are the Weenie Hut Jr. version of implicits. You get all the downsides of context dependent code and pain for library maintainers, but in place of the really cool features (like being external code being able to implement an interface on a type they didn't define) we only get the most vapid benefit.

Method chaining.

Alternatives

1. Use a box

If you are working in a language which doesn't have extension methods, but you feel in your bones a strong desire to chain methods, try making a box.

import java.util.function.Function;

record Box<T>(T value) {
    <R> Box<R> map(Function<? super T, ? extends R> f) {
        return new Box<>(f.apply(value));
    }
}

If you box up the value you want to chain methods on then calling instance methods will actually look the same as externally defined ones.

void main() {
    String name = "  SCRAPPY   ";
    name = new Box<>(name)
            .map(String::toLowerCase)
            .map(StringUtils::capitalizeFirstLetter)
            .map(String::strip)
            .map(s -> s.concat(" Dappy doo"))
            .value();
    System.out.println(name);
}

Is this better than the code without chaining? Debatable. I lean towards no, but if "fluent chaining" is the goal, this achieves the goal. And, unlike a full-blown language feature, it doesn't affect the lives of those for whom method chaining is not an emotional priority.

Extend the type

If the author of a type is okay with you extending it and is ready to consider whatever extensions might exist in the wild when they make new versions of a library, they can make their class open to extension.

class Dog {
    void bark() {
        System.out.println("Bark!");
    }
}
class Dalmatian extends Dog {
    void playFetch() {
        System.out.println("Throwing stick...");
        dog.bark();
        System.out.println("Stick retrieved.");
    }
}

Does this have downsides? Yes, most definitely. You cannot subclass String and that's maybe 50-60% of why people want extension methods as a feature.

But its at least a mechanism that a library maintainer has control on whether they opt into.

Add a uniform calling syntax

Some languages don't have a special syntax for calling methods defined alongside a type. Accordingly, such languages often do not have an equivalent to extension methods.

import String.Extra

name: String 
name "   shaggy  rodgers "
    |> String.trim -- Defined alongside String
    |> String.Extra.toSentenceCase -- Defined by third party

So one possible path for a language to take would be to appease the method chaining junkies and add a new way to invoke methods that chains with instance methods.

void main() {
    String name = "  SCRAPPY   ";

    name = name
            .toLowerCase()
            |[StringUtils::capitalizeFirstLetter]
            .strip()
            .concat(" Dappy doo");

    System.out.println(name);
}

This is one of the proposed directions that JavaScript might take. It has its downsides as well, but they are different downsides.

Use default interface methods (or an equivalent)

While this doesn't help you add methods to arbitrary types you did not make, you can use interfaces to add methods to things in most languages that have them.

import java.util.function.Consumer;

interface IterableExtended<T> extends Iterable<T> {
    default void forEachTwice(Consumer<? super T> consumer) {
        this.forEach(t -> {
            consumer.accept(t);
            consumer.accept(t);
        });
    }
}
class Eight implements IterableExtended<Integer> {
    private boolean gotEight = false;
    
    public boolean hasNext() {
        return !gotEight;
    }
    
    public Integer next() {
        gotEight = true;
        return 8;
    }
}
void main() {
    var eight = new Eight();
    eight.forEachTwice(System.out::println);
}

This is a sort of extension method, it just is a technique that only works at the declaration site, not for arbitrary consumers to add.

Deal with it.

void main() {
    String name = "  SCRAPPY   ";

    name = name.toLowerCase();

    name = StringUtils.capitalizeFirstLetter(name);

    name = name
            .strip()
            .concat(" Dappy doo");

    System.out.println(name);
}

Conclusion

It is fine to like extension methods. It is also fine to think they are worth the tradeoffs.

What stinks is that people act like there aren't tradeoffs and that they are purely positive. The sort of vapid "why don't they just add extension methods? Idiots." infects discourse and, while I have no illusions anything I write can stop it, I hope that at least some people now understand why a language might choose to not have them.


<- Index