How can I avoid constantly having to change an interface when adding new features to a system?

Viewed 356

At my work, I'm trying to create more modular systems, as we tend to use similar mechanics in our games that have minor variances. To do this, I have been making use of interfaces, but have been getting stumped on certain problems, particularly ones relating to the addition of small features.

EXAMPLE:

Take for instance our evolution system. I have created the IEvolvable interface, which has a property for the evolution level and an Evolve() method.

public interface IEvolvable
{
    int evolution { get; }

    bool IncreaseEvolution(int numEvolutions);
}

I then have an implementation of this interface on a Character class, and based on some conditions via my Evolution handling class, I want to evolve my character.

public class EvolutionHandler
{
    public IEvolvable evolvable;

    public void TryEvolveCharacter
    {
        if(someCondition)
        {
            evolvable.IncreaseEvolution(1);
        }
    }
}

Then, later down the line, we say, we want the character to evolve based on level! Fantastic. We have an ILevellable interface which contains Level, xp, etc.

public interface ILevellable
{
    int Level{ get; }
    int MaxLevel{get;}
    int XP {get;}
    bool LevelUp(int numLevels);
}

We can use this data to control when evolution takes place based on the change in level. But here's my problem:

My evolve handler class interfaces with IEvolvable... not ILevellable... So what do I do?

I can have IEvolvable extend ILevellable or vice-versa... or I can create a new interface which extends IEvolvable and ILevellable. Now I also have to modify my evolve handler to accomodate for these changes.

But what happens if we don't want the evolve handler to take into consideration the Level anymore in our new game? Do use the old code? Was I supposed to extend my old code to include the Ilevellable interfacing?

public interface ILevelEvolver : ILevellable, IEvolvable
{
}

public class EvolutionHandler2
{
     public ILevelEvolver levelEvolvable;

    public void TryEvolveCharacter
    {
        if(levelEvolvable.Level > 10)
        {
            evolvable.IncreaseEvolution(1);
        }
    }
}
4 Answers

the key words are :

  • separate what varies from what stay the same
  • one of SOLID principles : open for extension closed for modification

finally in your case would use Strategy pattern :

public interface IEvilutionChecker{

    bool AllowEvolution();
}


public class EvolutionCheckerA : IEvilutionChecker{
    private ILevellable levelEvolvable;
    public EvolutionCheckerA(ILevellable levelEvolvable){
        this.levelEvolvable = levelEvolvable;
    }
    public bool AllowEvolution(){
        return levelEvolvable.Level > 10;
    }
}

public class EvolutionCheckerB : IEvilutionChecker{
    private IEvolvable evolvable;
    public EvolutionCheckerB(IEvolvable evolvable){
        this.evolvable = evolvable;
    }
    public bool AllowEvolution(){
        return someCondition;
    }
}

public class EvolutionHandler2
{
    public IEvolvable evolvable; 
    public IEvilutionChecker EvolutionChecker {get;set;};

    public void TryEvolveCharacter
    {
        if(EvolutionChecker.AllowEvolution())  
        {
            evolvable.IncreaseEvolution(1);
        }
    }
}

The interfaces should not extend each other. Keep them separated. Also you should keep concepts separated. By that, EvolutionHandler should only accept IEvolable. In TryEvolveCharacter method, you can check if the property is a ILevelable. Take a look at the code:

class EvolutionHandler
{
    public IEvolable Evolable { get; set; }

    public void TryEvolveCharacter()
    {
        if (Evolable is ILevelable levelable && levelable.Level > 10)
        {
            Evolable.IncreaseEvolution(1);
        }
        else if (someCondition)
        {
            Evolable.IncreaseEvolution(1);
        }
    }
}

so at the future, if a character extends ILevelable, that level will be considered, if not, someCondition take place.

Once you are running into these types of issues it becomes evident I think that OOP has limitations, or rather it makes some things too easy. That doesn't mean it should be scrapped entirely and something else adopted, there's a lot we can still use it for. What if rather than using the interface you make meaningful changes to directly you pass around a service interface that acts as an adapter to the internal interface.

public interface IEvolutionService {
    TryEvolveCharacter(IEvolvable evolvable); 
}

The concrete implementation can have something like

public void TryEvolveCharacter(IEvolvable evolvable){
        if (evolvable.Level > 10) {
            evolvable.IncreaseEvolution(1);
            ..Maybe do something new that the IEvolvable just exposed but without changing our consumed interface!
        }
}

It does add code and things to make these but you have options there too, a single service can stand in for multiple interfaces, but then you are violating the Single Responsibility Principle in SOLID, and basically just making things more complex than they should in an effort at making it less complex.

You could make this a method on static class, although that interferes with testability, so I'd say refactoring and adding in a new service to handle things like service.TryEvolveCharacter(someIEvolvable). You'd still have to maintain the interface on your public facing service, but that could be more manageable than the raw interface with nothing abstracted in front of it.

I gave my answer to be as close to your question as possible, but to me it is still less than ideal. I would consider having immutable structs (which can have interfaces, and also stick to the L2 CPU cache) for the data and passing those to services (which would be pure functions, that is to say stateless, they only deal with what is passed in). If you are writing game code and performance is an issue then that's going to be very useful. If you were only using games as a metaphor maybe less so :) A helpful article on structs, L2, and performance

In many cases, having an interface that includes members which would be meaningful for some implementations but not others can be a better pattern than trying to use different interfaces for different combinations of functionality. As a simple example, if Java or .NET had included in their basic enumerable interface a function to report a count if available, along with one to indicate if and how the count would be performed, then a wrapper class that concatenates two enumerations could efficiently report how many elements were in the combined enumeration if the constituent enumerations supported a count function, and could also let clients know whether its count function would be efficient and/or cacheable.

Another pattern that can be useful is for an interface to include asXX function which a class may implement as either returning a reference to itself (if it supports XX functionality) or constructing a wrapper object of suitable type. If XX is a wrapper-class type, functionality may be added to the wrapper class without having to change the interface that includes the asXX member or implementations thereof.

Related