1. Polymorphism Isn't Always Magic
If you look at beginner examples, polymorphism in C# seems like a total win for common sense: inherit, override, call everything through the base type—and it just works. But in practice, there are some gotchas. Let's get to know them better.
Method Hiding and the new Keyword
Imagine we have a base class and a derived class, each declaring a method with the same name, but without the override keyword. If the derived class defines such a method again, but without override, it hides the base method's implementation instead of overriding it. The compiler, like a caring parent, will immediately warn you and suggest you explicitly specify new:
class Animal
{
public void Speak()
{
Console.WriteLine("Animal makes a sound.");
}
}
class Cat : Animal
{
public new void Speak()
{
Console.WriteLine("Meow!");
}
}
// Usage
Animal animal = new Cat();
animal.Speak(); // Will print: "Animal makes a sound."
Whoa! Even if we create an object of type Cat and put it in a variable of type Animal, the original base class method gets called. Why? Because the method wasn't declared as virtual! Trap number one: if you want to use polymorphism, don't forget about the virtual and override keywords. Use new only if you really want to hide, not override, a method (which, by the way, is needed super rarely and only for good reasons).
Constructor Calls and Polymorphism
Another not-so-obvious thing: constructors aren't virtual. If you declare a constructor in the base class and another in the derived class, they're not polymorphic. Here's an example:
class Animal
{
public Animal()
{
Console.WriteLine("Animal constructor");
}
}
class Cat : Animal
{
public Cat()
{
Console.WriteLine("Cat constructor");
}
}
// Usage
Animal animal = new Cat();
// Will print:
// Animal constructor
// Cat constructor
But if you call methods from the base class constructor that might be overridden in the derived class, the result can be unexpected—a virtual method will be called before the derived class is initialized! That's a good reason not to call virtual/abstract methods in constructors.
The "Broken" Encapsulation Problem with override
Virtual methods are cool, but if you designed a base class expecting a certain method to always behave a certain way, and then that method gets overridden in a child class and starts breaking your logic—you can get some nasty bugs.
class Animal
{
public virtual void Eat()
{
Console.WriteLine("Animal eats.");
}
public void Live()
{
Eat(); // Might call any overridden version!
}
}
class Cat : Animal
{
public override void Eat()
{
Console.WriteLine("Cat eats fish.");
}
}
Animal a = new Cat();
a.Live(); // Will print "Cat eats fish."
If in the base class Animal the Eat() method printed "animal eats", but then in the derived class you added something dangerous to the overridden Eat(), it could break the whole class. This problem is called violating the Liskov Substitution Principle (LSP). When designing, always think: will the child class behavior still make sense compared to the base?
Casting and Type Conversion Issues
Polymorphism lets you keep different objects in one "pile": for example, a list of the base type, which can have both dogs and cats (both inherit Animal). But if you want to call something specific:
List<Animal> pets = new List<Animal> { new Cat(), new Dog() };
foreach (var pet in pets)
{
if (pet is Cat cat)
{
cat.Purr();
}
}
If you forget to check the type and do a careless cast, you'll get the nasty InvalidCastException. Sometimes this leads to too many type checks, makes the code messy, and hints that maybe your design needs some work.
2. Problems with Abstraction
Abstraction is an awesome tool for making it easier for users to work with objects and for limiting access to internal state. But there are some pitfalls here too!
Going Overboard with Abstraction Levels (Over-Abstraction)
Some beginner (and not only beginner) devs get so into "proper OOP" that they create whole layer cakes of base classes, interfaces, and abstract layers. In the end, even the author can't figure out how it works.
interface IAnimal
{
void Speak();
}
abstract class Feline : IAnimal
{
public abstract void Speak();
}
class Cat : Feline
{
public override void Speak()
{
Console.WriteLine("Meow!");
}
}
Like, why have an intermediate abstract class if it adds nothing? Abstraction for the sake of abstraction just makes maintenance harder and the architecture more confusing.
Poorly Thought-Out Hierarchy
Think about what happens if you move some action to the very top of the hierarchy, but it doesn't actually fit everyone:
abstract class Animal
{
public abstract void Fly();
}
class Cat : Animal
{
public override void Fly()
{
throw new NotImplementedException("Cats don't fly!");
}
}
You'll either have to fill child classes with stubs that throw exceptions, or live with the fact that your interface doesn't match reality. This is a classic anti-pattern: "wrong hierarchy." In these cases, it's better to move such methods to separate interfaces (like IFlyable).
Tip: don't try to make one abstraction "for every possible case."
Problems with Abstract Classes and API Changes
As soon as an abstract class hits production and people start inheriting from it, any changes become risky. Adding a new abstract method means all inheritors must implement it—or their code just won't compile. This makes maintaining libraries and public APIs a pain.
That's exactly why default interface methods were introduced (see lecture 116): they let you extend interfaces without having to immediately change all existing code.
Breaking Encapsulation with Abstraction
When you make a class abstract, you often have to declare its members as protected so derived classes can access them. This often leads to leaking internal logic that really should be hidden. As a result, inheritors get access to data and operations that, if messed with, can break the base class's internal consistency.
3. Real-World Error Scenarios
So you don't think this only happens in homework, let's look at some real-life examples—even experienced devs step on these rakes sometimes.
Example with Methods Not Declared as virtual
Let's say we're expanding our learning app for logging (see Day 24). Suppose we have a base logger:
class BaseLogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
class FileLogger : BaseLogger
{
public void Log(string message)
{
// Write to file
Console.WriteLine("To file: " + message);
}
}
// Usage:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Expectation: "To file: Hello!", reality: "Hello!"
The FileLogger implementer thought they overrode the method, but forgot to add override and didn't make the base method virtual. So the call goes to the base version.
Recommendation: always mark methods you want to override as virtual in the base class and override in derived classes.
Example of Bad Abstraction: "Flexible" Animals
Let's stick with animals! Let's make an IFlyable interface so we don't force all animals to implement a Fly method:
interface IFlyable
{
void Fly();
}
class Bird : IFlyable
{
public void Fly() => Console.WriteLine("Bird flies!");
}
class Cat
{
// Cat doesn't implement IFlyable
}
Now you can write a function that works with "flyers" without touching cats:
void MakeItFly(object creature)
{
if (creature is IFlyable flyingThing)
{
flyingThing.Fly();
}
else
{
Console.WriteLine("This creature can't fly.");
}
}
This way you don't mess up your architecture with fake abstract methods.
Problems with "Rigid" Abstract Classes and Extensibility
Imagine you released a library with this abstract class:
public abstract class Creature
{
public abstract void DoAction();
}
Users of your library start making their own classes inheriting from it. A year later, you want to expand the API and add:
public abstract class Creature
{
public abstract void DoAction();
public abstract void Sleep(); // New method!
}
Now all user classes won't compile because they have to implement the new abstract method. That's a good reason to be super careful when designing abstractions and, if possible, prefer interfaces with default methods.
4. Tips to Avoid Common Pitfalls
Let your apps be as flexible as gymnasts, but don't break your legs!
- Don't overuse inheritance: if you can use composition (embedding one object in another), do that.
- Only make methods virtual if you know for sure they need to be overridden.
- Don't declare pointless abstract classes or create a hierarchy "just in case."
- Check your logic when overriding methods: don't break the invariants (rules) of base classes.
- Don't add new abstract methods to public base classes and interfaces after publishing a library.
- Use interfaces for loose coupling between parts of your program.
- To extend APIs, use Default Interface Methods.
GO TO FULL VERSION