Introduction
Welcome to Part 1 of "Diving in OOP." My goal with this series is simple — cover every OOP concept properly, the first time, so you're never stuck googling "what does this even mean" halfway through a tutorial.
This article is for beginners hunting for OOP concepts, and honestly, also for experienced folks who just want a quick refresher before an interview (we've all been there — five years of experience and suddenly blanking on what "method overloading" even means the moment someone asks).
We'll keep theory short and code long. C# is our language throughout. And fair warning — we're going to break things on purpose, just to see what error C# throws at us. That's usually the fastest way to actually learn a rule, instead of just reading it and nodding along.
Here's the series roadmap:
Diving in OOP (Polymorphism and Inheritance – Part 1) — you're here, welcome
Diving in OOP (Polymorphism and Inheritance – Part 2)
Diving in OOP (Polymorphism and Inheritance – Part 3)
Diving in OOP (Access Modifiers)
Diving in OOP (Properties)
Pre-requisites
You should know basic C# syntax and have a rough idea of what OOP terms mean, even if you've never really used them on purpose.
What is OOP, Anyway?
OOP stands for Object-Oriented Programming — organizing your code around objects, instead of a pile of loose functions floating around acting on random data (which, if you've ever opened a really old codebase, you already know is a special kind of nightmare).
An object is one instance of a class. Every object from the same class shares the same shape, but carries its own values — kind of like how every coffee cup at a café is shaped the same, but each one holds a different order.
OOP makes big software manageable. You can change one part without the whole thing collapsing like a Jenga tower — which, let's be honest, is exactly what procedural spaghetti code feels like after year three.
The Five OOP Concepts (Speed Round)
Abstraction — Hiding the messy internal details from whoever's using your code. You press a button on a coffee machine, and coffee comes out. You don't need to know about the pressure valve doing its business inside.
Inheritance — Reusing logic from an existing class instead of rewriting it. The "why cook from scratch when there are leftovers" principle of programming.
Encapsulation — Bundling data and behavior together, and controlling what's visible to the outside world.
Polymorphism — "Many forms." One operation, many possible behaviors, depending on what you feed it.
Message Communication — What happens when one object calls a method on another. Basically, objects gossiping to get things done.
Okay, theory's done. Time for the fun part — let's go break some code.
Compile-Time Polymorphism: Method Overloading
We're building a tiny Coffee Shop Order System, and we're going to use it to poke at every corner of method overloading and the params keyword — including a few rules that genuinely surprise people the first time they hit them (myself included, several interviews ago).
The Setup
Let's create a class called Barista with a method named TakeOrder, written three different ways.
Barista.cs:
public class Barista
{
public void TakeOrder(int cupSize)
{
Console.WriteLine($"Order: size {cupSize}");
}
public void TakeOrder(string drinkName)
{
Console.WriteLine($"Order: {drinkName}");
}
public void TakeOrder(string drinkName, int cupSize)
{
Console.WriteLine($"Order: {drinkName}, size {cupSize}");
}
}
Program.cs:
var barista = new Barista();
barista.TakeOrder(12);
barista.TakeOrder("Cappuccino");
barista.TakeOrder("Cappuccino", 12);
Output:
Order: size 12
Order: Cappuccino
Order: Cappuccino, size 12
Three methods, same name, different parameters — and our Barista never gets confused about which one you meant, the same way a real barista doesn't panic when you just say "the usual." This is method overloading. You're not inventing ten different method names for ten slightly different orders — you reuse the name, and let the parameters do the talking.
Point to Remember: C# tells methods apart by their parameters, not by the name alone. A method's full identity — its signature — is its name plus the number and types of its parameters.
Return Type? C# Doesn't Care
public void TakeOrder() { }
public int TakeOrder() { }
Boom, compile error:
Error: Type 'Barista' already defines a member called 'TakeOrder' with the same parameter types
Both methods take nothing and differ only in what they hand back. C# looks at this and shrugs — "same signature to me." It genuinely does not care what you're returning when deciding if two methods are "different enough."
Point to Remember: Return type is never part of a method's signature. If two methods only differ by return type, that's not an overload — that's just a red squiggly line waiting to happen.
static Doesn't Get You Out of This Either
static void TakeOrder(int cupSize) { }
public void TakeOrder(int cupSize) { }
public void TakeOrder(string drinkName) { }
Same error again:
Error: Type 'Barista' already defines a member called 'TakeOrder' with the same parameter types
You'd think slapping static on one of them would be enough of a disguise. It isn't. C# looks straight through it.
Point to Remember: Modifiers like static are not part of a method's signature. Nice try though.
ref and out Are... Complicated
private void TakeOrder(int cupSize) { }
private void TakeOrder(out int cupSize) { cupSize = 12; }
private void TakeOrder(ref int cupSize) { }
New error, slightly more dramatic this time:
Error: Cannot define overloaded method 'TakeOrder' because it differs from another method only on ref and out
C# is basically saying: "I see that these are different — but not different enough for me to let all three of you coexist peacefully." It's a genuinely odd rule the first time you meet it, and it exists specifically to stop you from writing call sites that would confuse literally everyone, including future-you.
Point to Remember: A method's signature includes how a parameter is passed — value, ref, or out — but C# still won't let overloads lean on just that difference.
Enter params: The Keyword That Lets You Order However Much You Want
There are four ways to hand parameters to a method:
By value
By reference (ref)
As output (out)
As a parameter array (params)
We've poked at the first three. Now let's really dig into params — because this is where things get genuinely fun (and occasionally genuinely confusing, in the "wait, why did that happen" sense).
Parameter Names Have to Be Unique (Shocking, I Know)
public void TakeOrder(int size, string size) { }
public void ShowOrder(int total)
{
string total;
}
Both refuse to compile:
Error 1: The parameter name 'size' is a duplicate
Error 2: A local variable named 'total' cannot be declared in this scope because it would give a different meaning to 'total', which is already used in a parent or current scope
Point to Remember: Parameter names must be unique in a method, and you can't reuse one as a local variable name either. C# takes names about as seriously as baristas take spelling them wrong on the cup.
ref Means "Same Cup, Different Label"
public class LoyaltyCard
{
private string customerName = "Pari";
public void Upgrade()
{
Rename(ref customerName, ref customerName);
Console.WriteLine(customerName);
}
private void Rename(ref string first, ref string second)
{
Console.WriteLine(customerName);
first = "Pari (Silver)";
Console.WriteLine(customerName);
second = "Pari (Gold)";
Console.WriteLine(customerName);
customerName = "Pari (VIP)";
}
}
var card = new LoyaltyCard();
card.Upgrade();
Output:
Pari
Pari (Silver)
Pari (Gold)
Pari (VIP)
You can pass the same ref variable twice into the same method call, and yes, it's exactly as chaotic as it sounds. first, second, and customerName are all literally the same box in memory with three different labels stuck on it. Change any label's contents, and you've changed all of them — because there was only ever one box.
params: Order As Many Items As You Want
public class OrderTicket
{
public void Print()
{
PrintItems(10, "Latte", "Muffin", "Croissant");
PrintItems(5, "Espresso");
PrintItems(0);
}
private void PrintItems(int discountPercent, params string[] items)
{
foreach (var item in items)
Console.WriteLine($"{item} — {discountPercent}% off");
}
}
var ticket = new OrderTicket();
ticket.Print();
Output:
Latte — 10% off
Muffin — 10% off
Croissant — 10% off
Espresso — 5% off
Notice the last call, PrintItems(0), printed nothing at all — zero items, zero output, and zero complaints from C#. That's params for you: pass as few or as many matching-type values as you like, and C# quietly bundles them into an array behind the scenes. No overload army required.
Point to Remember: params can only be the very last parameter in a method. The "however many you want" part always goes at the end of the line — just like the actual coffee queue.
Try sneaking a parameter in after it:
private void PrintItems(int discountPercent, params string[] items, int b) { }
C# shuts that down immediately:
Error: A parameter array must be the last parameter in a formal parameter list
C# Is Smarter Than It Looks
public class ReceiptPrinter
{
public void Print()
{
PrintTotals(50, 90, 85);
PrintTotals(50, 90);
PrintTotals(50);
}
private void PrintTotals(int bonus, params int[] totals)
{
foreach (var t in totals)
Console.WriteLine($"{t} (+{bonus} loyalty points)");
}
}
var receipt = new ReceiptPrinter();
receipt.Print();
Output:
90 (+50 loyalty points)
85 (+50 loyalty points)
90 (+50 loyalty points)
Point to Remember: When the second-to-last argument matches the params array's type, C# correctly figures out where the fixed parameter ends and the flexible pile begins — without you having to spell it out.
params Must Be Single-Dimensional (No Fancy Business)
private void PrintItems(int discount, params string[][] items) { }
private void PrintItems(int discount, params string[,] items) { }
Both throw:
Error: The parameter array must be a single dimensional array
Point to Remember: A params array must be single-dimensional. Jagged arrays ([][]) are fine — actual multi-dimensional arrays ([,]) are not invited to this party. And no, you can't combine params with ref or out either. C# has limits.
You Can Hand Over a Whole Array Instead of Listing Items One by One
public class GroupOrder
{
public void Print()
{
string[] customers = { "Manoj", "Pankaj", "Amol" };
PrintNames(10, customers);
}
private void PrintNames(int discount, params string[] names)
{
foreach (var name in names)
Console.WriteLine($"{name}: {discount}% off");
}
}
var group = new GroupOrder();
group.Print();
Output:
Manoj: 10% off
Pankaj: 10% off
Amol: 10% off
Handy — you don't have to type out each name individually. Pass the whole array, and C# treats it exactly as if you'd listed every name one by one.
But You Can't Mix an Array With a Loose Extra Value
public class MixedGroupOrder
{
public void Print()
{
string[] customers = { "Manoj", "Amol" };
PrintNames(10, customers, "Pankaj");
}
private void PrintNames(int discount, params string[] names)
{
foreach (var name in names)
Console.WriteLine($"{name}: {discount}% off");
}
}
Output:
Error: The best overloaded method match for 'MixedGroupOrder.PrintNames(int, params string[])' has some invalid arguments
Error: Argument 2: cannot convert from 'string[]' to 'string'
Feels like it should work, right? Just tack "Pankaj" onto the array and move on. But C# already decided, before the call even happens, whether it's building the params array from loose values or accepting an existing array wholesale. It won't do both in the same breath — no mixing an already-made fruit salad with one extra loose grape.
Arrays Passed Through params Are Still the Real Deal
public class DiscountUpdater
{
public void Apply()
{
int[] discounts = { 10, 20, 30 };
BoostSecond(999, discounts);
Console.WriteLine(discounts[1]);
}
private void BoostSecond(int marker, params int[] values)
{
values[1] = 500;
}
}
var updater = new DiscountUpdater();
updater.Apply();
Output:
500
discounts[1] started at 20. Inside BoostSecond, changing values[1] to 500 changed the original array too — because arrays are reference types, and values is pointing at the exact same block of memory as discounts, params or no params.
But Loose Values Get Copied, Not Shared
public class SafeUpdater
{
public void Apply()
{
int originalDiscount = 15;
BoostSecond(999, 500, originalDiscount, 700);
Console.WriteLine(originalDiscount);
}
private void BoostSecond(int marker, params int[] values)
{
values[1] = 9999;
}
}
Output:
15
This time, C# built a fresh array — {500, 15, 700} — as a copy. BoostSecond has no idea originalDiscount even exists once it's inside; it only sees the array it was handed. So originalDiscount stays blissfully unaware and unchanged at 15.
A Specific Match Always Wins Over params
public class PreferenceDemo
{
public void Run()
{
Report(50);
Report(50, 75);
Report(50, 75, 90, 60);
}
private void Report(int a, int b)
{
Console.WriteLine($"Exactly two: {a}, {b}");
}
private void Report(params int[] values)
{
Console.WriteLine("Handled by params");
}
}
Output:
Handled by params
Exactly two: 50, 75
Handled by params
With one argument, only params can catch it — a one-item array is still a valid array. With exactly two arguments, C# has options, and it always picks the specific, non-params match first. params only gets called in when nothing more specific fits — the understudy who only goes on stage when the lead actor is out sick.
Point to Remember: params is always C#'s last resort, never its first choice.
The Object Array Plot Twist
public class MixedTray
{
public static void Show(params object[] items)
{
foreach (var item in items)
Console.Write($"{item.GetType().FullName} ");
Console.WriteLine();
}
}
object[] tray = { 250, "Latte", 12.5 };
object boxedTray = tray;
MixedTray.Show(tray);
MixedTray.Show((object)tray);
MixedTray.Show(boxedTray);
MixedTray.Show((object[])boxedTray);
Output:
System.Int32 System.String System.Double
System.Object[]
System.Object[]
System.Int32 System.String System.Double
First call: tray is passed as-is, unpacked correctly into int, string, double. Second call: we explicitly cast tray to object — and since there's no automatic path back from object to object[], C# treats the whole tray as one single item, wrapping it inside a brand-new one-element array. Third call, same story, since boxedTray is already typed as object. Fourth call, cast it back to object[] explicitly, and normal service resumes.
Quick proof:
public class ProofTray
{
public static void Show(params object[] items)
{
Console.WriteLine(items.GetType().FullName);
Console.WriteLine(items.Length);
Console.WriteLine(items[0]);
}
}
object[] tray = { 250, "Latte", 12.5 };
ProofTray.Show((object)tray);
Output:
System.Object[]
1
System.Object[]
There it is — items really does hold just one element, and that one element is the entire original tray, sitting there disguised as a single object. Sneaky.
Conclusion
That's Part 1 done — compile-time polymorphism, method overloading, and the surprisingly deep rabbit hole that is the params keyword. If you made it this far without skimming, you now know more about params than most developers who've been writing C# for years (seriously, ask someone about the object array trap at your next code review and watch their face).
Here's the full cheat sheet, one more time:
C# tells methods apart by parameters, not name.
Return type is never part of the signature.
static and other modifiers don't count either.
A signature = name + parameter count + parameter types + how they're passed (value/ref/out).
Parameter names must be unique, and can't double as local variable names.
Pass by value copies data; ref/out share the actual memory address.
params must always be the last parameter.
C# correctly figures out the split when the second-to-last argument matches the params type.
params arrays must be single-dimensional.
We'll keep this same hands-on, break-it-to-understand-it approach through the rest of the series. Happy Coding! ☕