Akjo
Akjo

Reputation: 23

How do I create a list with multiple types that have the same superclass, without losing the type

So I have an abstract class Map with an ArrayList of BaseElement's.

BaseElement is also an abstract class, which is extended by three classes: Node, Way and Relation. It only has an attribute called id.

Inside the class Map i want to have a addElement method and a getElement method. The addElement method should add the element to the list and return the element back, without losing it's type. The getElement should find the element inside the list, by it's id and return it (also without losing the type).

Now I tried to use type generics to do this, but I'm still pretty new to type generics.

Currently this is my Map class:

public abstract class Map {
    private final String name;
    private final ArrayList<BaseElement> elements;

    protected Map(String name) {
        this.name = name;
        this.elements = new ArrayList<>();
        this.defineElements();
    }

    protected abstract void defineElements();

    protected <T extends BaseElement> T addElement(T element) {
        elements.add(element);
        return element;
    }

    public BaseElement getElement(int elementID) {
        return elements.stream()
                .filter(element -> element.getID() == elementID)
                .findFirst()
                .orElse(null);
    }

    public String getName() {
        return name;
    }
}

For example I want to add an element with addElement like so...:

public class EarthMap extends Map {
    public EarthMap() {
        super("Earth");
    }

    @Override
    protected void defineElements() {
        Node exampleNode = this.addElement(BaseElementFactory.createNode(new Location(1.2345, 2.3456)));
    }
}

and get the element again with getElement like so:

public class Main {
    public static void main(String[] args) {
        new EarthMap().getElement(0).getLocation() // getLocation() is defined in class Node, not in BaseElement
    }
}

I'm really not sure how to do this, without breaking the logic here. I have to store multiple objects that extend from BaseElement in a list, without losing their type.

Can I do this with type generics? Or do I have to use another technique?

Upvotes: 2

Views: 1654

Answers (2)

Basil Bourque
Basil Bourque

Reputation: 338386

tl;dr

An object does not lose its type when retrieved from a Generics-savvy collection (having a parameterized type). You can test for the subtype by using instanceof and then cast.

You asked how to call a subclass-specific method on an object retrieved from Generics-enabled collection:

new EarthMap().getElement(0).getLocation() // getLocation() is defined in class Node, not in BaseElement

Using the latest features in Java, this work can be as simple as:

BaseElement element = new EarthMap().getElement(0) ;
switch ( element )
{
    case Node node -> node.getLocation() ;  // Method defined in class `Node`, not `BaseElement`.
    case Way way -> way.someWaySpecificMethod();
    case Relation relation -> relation.someRelationSpecificMethod();
}

… with the compiler verifying you have covered all possible cases.

Such an approach can be used instead of the cumbersome Visitor Pattern mentioned in the Comments on this Question. The pattern-matching and sealed-class features will generally make for more transparent and straightforward code.

Type is not lost

When you retrieve an object from a container using Java Generics, the object does not “lose its type”. The object’s type is masked, being returned as the more general type parameterized on the collection. But the object still knows its own type.

Polymorphism as proof

We can verify this fact by way of polymorphism. If the subclasses have their own distinct behavior, we should see that behavior rather than the superclass behavior if their type is preserved.

Let's use a simple example, a superclass of Animal with two subclasses, Dog & Cat.

public abstract class Animal {
    public void eat () {
        System.out.println( "Eating." );
    }
}
public class Dog extends Animal {
    @Override
    public void eat () {
        System.out.println( "Eating meat and veg. Woof." );
    }
}
public class Cat extends Animal {
    @Override
    public void eat () {
        System.out.println( "Eating meat. Meow." );
    }
}

An app to call eat method on each of the animals in a sample collection.

List < Animal > animals =
        List.of(
                new Cat() ,
                new Cat() ,
                new Dog()
        );

animals.stream().forEach( Animal :: eat );

When run.

Eating meat. Meow.
Eating meat. Meow.
Eating meat and veg. Woof.

So, we can see the cats still know they are cats, and the dog still knows it is a dog.

Methods specific to a subclass

Your Question asks about methods not defined on the superclass, methods that are specific to a particular subclass.

For that, let’s add Dog#snooze method and a Cat#petMe method, each specific to that class only.

public class Dog extends Animal {
    @Override
    public void eat () {
        System.out.println( "Eating meat and veg. Woof." );
    }

    public void snooze () {
        System.out.println( "I’m gonna take a nap at your feet, if you don’t mind." );
    }
}
public class Cat extends Animal {
    @Override
    public void eat () {
        System.out.println( "Eating meat. Meow." );
    }

    public void petMe () {
        System.out.println( "Pet me, now, servant!" );
    }
}

To call those specific methods, we need to cast each object from the generic Animal to its more specific concrete class.

for ( Animal animal : animals )
{
    if ( animal instanceof Dog )
    {
        Dog dog = ( Dog ) animal;
        dog.snooze();
    }
    if ( animal instanceof Cat )
    {
        Cat cat = ( Cat ) animal;
        cat.petMe();
    }
}

When run.

Pet me, now, servant!
Pet me, now, servant!
I’m gonna take a nap at your feet, if you don’t mind.

Pattern Matching for instanceof

In Java 16 and later, we can now shorten that code testing instanceof. On a single line we can test, cast, and assign to a variable. See JEP 394: Pattern Matching for instanceof.

// Java 16+ ➤ JEP 394: Pattern Matching for instanceof
for ( Animal animal : animals )
{
    if ( animal instanceof Dog dog )
    {
        {
            dog.snooze();
        }
    }
    else if ( animal instanceof Cat cat )
    {
        cat.petMe();
    }
    else
    {
        System.out.println( "Oops, encountered unexpected type of animal." );
    }
}

Pattern Matching for switch

This work gets even simpler using a new feature being previewed in Java 17.

JEP 406: Pattern Matching for switch (Preview) brings that instanceof pattern-matching to the switch statement.

Beware: You need to set your project & runtime to enable this not-yet-official feature. By default, disabled.

// Preview feature in Java 17 ➤ JEP 406: Pattern Matching for switch (Preview)
for ( Animal animal : animals )
{
    switch ( animal )
    {
        case Dog dog -> dog.snooze();
        case Cat cat -> cat.petMe();
        case null -> System.out.println( "Oops, no animal." );
        default -> System.out.println( "Oops, encountered unexpected type of animal." );
    }
}

Sealed classes

Java 17, the current Long-Term Support (LTS) version, brings another new feature: Sealed classes. In a sealed class, you declare for a particular superclass all possible subclasses. The superclass is then considered closed for extension by any others. Therefore, the compiler can identify, at compile-time, a list of all subclasses.

See: JEP 409: Sealed Classes

Notice the default at bottom of that switch in our code above. It serves as a catch-all in case we have not accounted for all possible sub-types of Animal. The compiler can make use of the sealed-class list of known subclasses to tell if our switch statement has covered all possible cases.

If we seal our animal, dog, and cat classes:

// Mark the class as `sealed`. 
public abstract sealed class Animal permits Cat, Dog
{
    public void eat () {
        System.out.println( "Eating." );
    }
}
// Mark as `final` as one way to seal the superclass.
public final class Dog extends Animal {
    @Override
    public void eat () {
        System.out.println( "Eating meat and veg. Woof." );
    }

    public void snooze () {
        System.out.println( "I’m gonna take a nap at your feet, if you don’t mind." );
    }
}
// Mark as `final` as one way to seal the superclass.
public final class Cat extends Animal {
    @Override
    public void eat () {
        System.out.println( "Eating meat. Meow." );
    }

    public void petMe () {
        System.out.println( "Pet me, now, servant!" );
    }
}

… then we can further simplify that code above by omitting the default case. Furthermore, if we later add another subtype, the compiler will alert us by emitting a compiler error. If we edit our code to omit any of the subclasses (Dog case or Cat case here), the compiler likewise emits a compiler error.

// Preview feature in Java 17 ➤ JEP 406: Pattern Matching for switch (Preview)
// Combined with Java 17+ feature ➤ JEP 409: Sealed Classes
for ( Animal animal : animals )
{
    switch ( animal )
    {
        case Dog dog -> dog.snooze();
        case Cat cat -> cat.petMe();
        case null -> System.out.println( "Oops, no animal." );
        // NO LONGER NEEDED…  default -> System.out.println( "Oops, encountered unexpected type of animal." );
    }
}

Conclusion

If you want to collect BaseElement objects, then retrieve them and treat them as their more specific narrower type (Node, Way and Relation), just cast. Or let the latest Java features do the casting for you.

Upvotes: 2

user16669986
user16669986

Reputation:

Suggested changes to your code:

  1. public abstract class Main<BaseElement>

  2. private final ArrayList<BaseElement> elements;

  3. protected BaseElement addElement(BaseElement element)

There is no need to use extend as it is already an abstract class

after that, any subclass of BaseELement will work fine :)

Upvotes: 1

Related Questions