Running It & Output
Hello, World
The first difference is invisible here and matters everywhere: the Lua line is interpreted the moment it is read, while the C# line is compiled to an assembly first and only then run. What you see on this page is the same either way, because the compiler is fast enough to hide.
print("Hello, World!")Console.WriteLine("Hello, World!");Console is a class in the System namespace, and WriteLine is a static method on it. Lua's print is a plain global function, which is why it needs no qualification.print and io.write
Lua's
print takes any number of values and separates them with tabs. C# has two methods instead of one, split by whether a newline is appended, and neither of them joins a list of arguments for you.print("a", "b") -- several values, tab-separated
io.write("no newline")
print()
print(true)Console.WriteLine("a\tb"); // one value, so the tab is yours to add
Console.Write("no newline");
Console.WriteLine();
Console.WriteLine(true); // ToString() is called for youSo Lua's
print is closest to Console.WriteLine and Lua's io.write is closest to Console.Write. C# prints True with a capital letter, because it is calling Boolean.ToString() rather than formatting a keyword.Comments
-- A single-line comment.
--[[ A long comment
spanning several lines. ]]
print("commented")// A single-line comment.
/* A block comment
spanning several lines. */
/// <summary>A documentation comment, read by tooling.</summary>
Console.WriteLine("commented");The third form has no Lua counterpart. A triple-slash comment is XML that the compiler extracts into a documentation file and that editors show in tooltips, so it is part of the public surface of a method rather than a note to the next reader.
A file of statements, with no ceremony
Every C# tutorial written before 2020 opens with
class Program { static void Main(string[] args) { … } }, and that boilerplate is the main reason C# reads as heavyweight. It is optional now, and every C# example on this page relies on that.-- A Lua file is a chunk: statements, top to bottom, no wrapper.
local greeting = "ready"
print(greeting)// Since C# 9 a file can be statements too — no class, no Main.
var greeting = "ready";
Console.WriteLine(greeting);The compiler still generates the class and the
Main method; it just writes them for you. One consequence shows up throughout this page: type declarations must come after the loose statements in the file, because everything above them is being folded into Main.end becomes a brace, and semicolons are mandatory
Two syntactic taxes at once: the condition needs parentheses, and every statement needs a semicolon. Neither is optional the way Lua's semicolon is.
local ready = true
if ready then
print("inside the block")
endvar ready = true;
if (ready)
{
Console.WriteLine("inside the block");
}C# has no
then and no elseif — it is else if, two words. A single-statement body can drop the braces entirely, which is common for short guard clauses and is used in a few examples below.Static Types Arrive
local becomes var
These lines look like a rename, and the resemblance is genuinely useful — but
var is not Lua's local. It declares a variable whose type the compiler works out from the initializer and then enforces forever.local count = 3
local label = "items"
print(count)
print(label)var count = 3;
var label = "items";
Console.WriteLine(count);
Console.WriteLine(label);count is an int and label is a string, permanently. var saves you typing the type, not the type itself, which is why an uninitialized var is a compile error: there would be nothing to infer from.A variable's type is fixed
This is the single change with the widest blast radius. In Lua a variable is a name bound to whatever you last put in it; in C# it is a name with a type, and the assignment above is rejected before the program runs.
local total = 10
total = "ten" -- perfectly legal
print(total)int total = 10;
// total = "ten"; // error CS0029: cannot convert string to int
Console.WriteLine(total);The payoff is that a whole class of Lua bug — a value quietly becoming the wrong kind of thing three functions away from where it was set — is reported at the line that causes it. The cost is that you now have to decide, and say, what everything is.
A forgotten local cannot leak
Lua's most-cited footgun is that
local is opt-in, so a typo creates a global. C# removes the footgun by removing the category.local function configure()
threshold = 5 -- no 'local', so this is a GLOBAL
end
configure()
print(threshold) -- 5, and visible everywherevoid Configure()
{
var threshold = 5; // there is no way to leak this out
Console.WriteLine(threshold);
}
Configure();
// Console.WriteLine(threshold); // error CS0103: does not exist hereC# has no global variables at all. The nearest thing is a
static field on a class, which you have to declare deliberately and name through its type. A misspelled variable is a compile error rather than a new global with a null value.type() becomes GetType()
Lua's
type() returns one of eight strings, and that is the entire run-time type system. C# objects carry a full type identity you can query, but you rarely need to, because the compiler already knows.print(type(1))
print(type("a"))
print(type(true))
print(type(3.14))Console.WriteLine(1.GetType());
Console.WriteLine("a".GetType());
Console.WriteLine(true.GetType());
Console.WriteLine(3.14.GetType());Notice that Lua reports both
1 and 3.14 as number while C# distinguishes Int32 from Double. Lua 5.3 does draw that line internally — math.type reports it — but it is a subtype of one number type rather than two separate types.Division: the trap that costs everyone an hour
Read the first line of each column together. The same expression gives 3.5 in Lua and 3 in C#, and nothing warns you, because both answers are correct for their language.
print(7 / 2) -- 3.5 — / is ALWAYS float division
print(7 // 2) -- 3 — // is how you ask for floor division
print(7.0 // 2) -- 3.0 — and it keeps the float subtypeConsole.WriteLine(7 / 2); // 3 — int / int is INTEGER division
Console.WriteLine(7 / 2.0); // 3.5 — one float operand, so float division
Console.WriteLine(7.0 / 2); // 3.5 — either side will do itLua 5.3 settled this by making
/ always produce a float and adding // for floor division. C# instead resolves the operator from the operand types, so you opt into float division by making one operand a float. The bug this causes looks like an averaging function that returns whole numbers.nil becomes null, and null becomes a type question
Lua's
nil is a value like any other: it can live in any variable, and reading a missing table key hands you one. C# 8 turned the same idea into a compile-time claim, which is why the question mark above is load-bearing.local person = nil
print(person == nil)
local name = "Ada"
print(#name)string? person = null; // the ? is the TYPE admitting null
Console.WriteLine(person is null);
string name = "Ada"; // no ?, so the compiler tracks it as non-null
Console.WriteLine(name.Length);With nullable reference types on, calling a method on
person without checking it is a warning, and calling one on name needs no check at all. That is the shift: null stops being something you defend against everywhere and becomes something the type says up front.There Is No Truthiness
A condition must BE a bool
Lua's rule is famously narrow: only
nil and false are falsy, so 0 and "" are true. That rule is the thing you most often have to remember when moving between languages — and here it stops mattering entirely.local count = 0
if count then print("0 is TRUE in Lua") end
local text = ""
if text then print("so is the empty string") endvar count = 0;
// if (count) { } // error CS0029: cannot convert int to bool
if (count == 0) Console.WriteLine("a condition must BE a bool");
var text = "";
// if (text) { } // error CS0029 as well
if (text.Length == 0) Console.WriteLine("so you say what you mean");C# has no conversion from any other type to
bool, so there is no falsy set to memorize and no accidental truthiness to debug. The cost is verbosity: every check that Lua writes as if value then becomes if (value != null) or if (value.Count > 0), and you have to know which one you meant.x = x or default becomes ??=
The most common line in all of Lua has an exact C# counterpart, and the counterpart is better in one specific way.
local function greet(name)
name = name or "stranger"
return "Hello, " .. name
end
print(greet("Ada"))
print(greet(nil))string Greet(string? name)
{
name ??= "stranger";
return "Hello, " + name;
}
Console.WriteLine(Greet("Ada"));
Console.WriteLine(Greet(null));Lua's
or replaces anything falsy, so enabled = enabled or true can never be false — a bug every Lua programmer has written. ??= replaces null and nothing else, so the same line in C# does what it looks like it does.a and b or c becomes a real conditional
Lua has no ternary operator, so it borrows one out of
and and or. The borrowed version works until the middle value is itself falsy.local score = 42
local grade = score >= 40 and "pass" or "fail"
print(grade)var score = 42;
var grade = score >= 40 ? "pass" : "fail";
Console.WriteLine(grade);The Lua idiom silently returns
c whenever b is false or nil, which is why enabled and false or true yields true. C#'s ?: is a real conditional expression and cannot fall through, and the compiler additionally checks that both branches produce the same type.Strings
Strings are immutable in both
A convergence, and a useful one to know early: neither language lets you modify a string in place, so every habit you have about building strings carries over intact.
local greeting = "hello"
print(greeting:upper()) -- a NEW string
print(greeting) -- the original is unchangedvar greeting = "hello";
Console.WriteLine(greeting.ToUpper()); // a NEW string
Console.WriteLine(greeting); // the original is unchangedBoth languages also intern string literals, so identical literals are usually the same object. The practical consequence is the same in both: repeated concatenation allocates, and the fix is to accumulate elsewhere and join once — see the Gotchas section.
The .. operator becomes +
Lua gives concatenation its own operator so that
+ can stay arithmetic. C# overloads + for strings, which means it also coerces the number on the right.local parts = {}
for index = 1, 3 do
parts[#parts + 1] = "line " .. index
end
print(table.concat(parts, "; "))var parts = new List<string>();
for (int index = 1; index <= 3; index++)
{
parts.Add("line " + index);
}
Console.WriteLine(string.Join("; ", parts));Both languages convert the number to text automatically here. The difference to watch is that in C#
"1" + 2 is the string "12" while 1 + 2 is the number 3, so the operator's meaning depends on the operands — whereas Lua's .. is always concatenation and "10" + 1 is the number 11.Slicing: 1-based inclusive becomes 0-based plus a length
Three off-by-one hazards in one row. The index base changes, the second argument changes meaning from an end position to a count, and Lua's negative indices are spelled with a caret instead of a minus sign.
local word = "codecompared"
print(#word) -- 12
print(word:sub(1, 4)) -- "code" — start and END index, inclusive
print(word:sub(-8)) -- "compared" — a negative index counts backvar word = "codecompared";
Console.WriteLine(word.Length); // 12
Console.WriteLine(word.Substring(0, 4)); // "code" — start and LENGTH
Console.WriteLine(word[^8..]); // "compared" — index-from-end, then a rangeword[^8..] is C# 8 range syntax: ^8 means "eight from the end" and .. takes a slice, so it reads much like word:sub(-8) once you know it. The old spelling is word.Substring(word.Length - 8), which is exactly the arithmetic Lua's negative index saves you.string.format becomes $"…"
Lua's
string.format is C's printf, so the placeholders carry the types and sit apart from the values. C# puts the expression inside the string and the formatting after a colon.local name, count = "Ada", 3
print(string.format("%s has %d items", name, count))
print(string.format("%5d|%.2f", count, 3.14159))var name = "Ada";
var count = 3;
Console.WriteLine($"{name} has {count} items");
Console.WriteLine($"{count,5}|{3.14159:F2}");The interpolated form is checked at compile time: a misspelled variable name inside the braces is an error, where
%d against a string is a run-time error in Lua. After the comma comes the field width (negative for left alignment) and after the colon the format specifier, so {count,5} matches %5d and :F2 matches %.2f.string.upper(text) and text:upper() become one form
Lua gives you both spellings because
text:lower() is nothing but sugar: strings have a metatable whose __index is the string table. C# has no free-function form, and one of the three lines below is not a method call at all.local text = "Mixed Case"
print(string.upper(text)) -- the library-function form
print(text:lower()) -- the method form: sugar for string.lower(text)
print(#text)var text = "Mixed Case";
Console.WriteLine(text.ToUpper()); // there is only the method form
Console.WriteLine(text.ToLower());
Console.WriteLine(text.Length); // a PROPERTY, so no parenthesesLength is a property — it looks like a field, runs like a method, and takes no parentheses. Getting that wrong is the most common first-day C# error for someone arriving from a language where everything is either a field or a call, and the compiler's message names the fix.Lua patterns become real regular expressions
Lua deliberately ships its own small pattern language rather than a regular-expression engine, to keep the interpreter tiny. The character classes look familiar —
%d, %s, %w — but there is no alternation, no grouping for repetition, and % is the escape instead of \.local line = "user=ada id=42"
print(line:match("id=(%d+)"))
print((line:gsub("%s+", ",")))using System.Text.RegularExpressions;
var line = "user=ada id=42";
Console.WriteLine(Regex.Match(line, @"id=(\d+)").Groups[1].Value);
Console.WriteLine(Regex.Replace(line, @"\s+", ","));C# has the full engine, so alternation (
a|b), non-greedy quantifiers and lookaround all work. Two mechanical notes: @"…" is a verbatim string, which stops \d from being read as an escape sequence, and the extra parentheses in the Lua column discard gsub's second return value, the replacement count.Splitting a string, which Lua cannot do directly
There is no
string.split in Lua's standard library, which surprises everyone once. The idiom is a gmatch over "runs of anything that is not the separator", and it is worth recognizing on sight because it appears in every Lua codebase.local csv = "red,green,blue"
local colors = {}
for color in csv:gmatch("[^,]+") do
colors[#colors + 1] = color
end
print(string.format("%d\t%s", #colors, table.concat(colors, " | ")))var csv = "red,green,blue";
var colors = csv.Split(',');
Console.WriteLine($"{colors.Length}\t{string.Join(" | ", colors)}");The
gmatch idiom also silently drops empty fields, so "a,,b" yields two colors rather than three. Split keeps them, and takes an option to remove them if you would rather. This is the kind of place where C#'s larger standard library simply saves you a decision.One Table Becomes Many Types
The table splits in two
A Lua table is an array and a hash map at the same time, which is why the constructor above needs no explanation. C# makes you pick, because the type of a collection is part of its type.
local everything = { 10, 20, 30, name = "mixed" }
print(#everything)
print(everything.name)int[] numbers = [10, 20, 30];
var fields = new Dictionary<string, string> { ["name"] = "mixed" };
Console.WriteLine(numbers.Length);
Console.WriteLine(fields["name"]);The bracketed
[10, 20, 30] is a C# 12 collection expression: the same syntax initializes an array, a List<T> or a Span<T>, with the target type deciding which. It is the closest C# comes to Lua's table constructor, and it is new enough that older code will use new int[] { … } instead.Indexing starts at zero
The change you will feel most often, and the one no amount of understanding prevents you from getting wrong occasionally.
local colors = { "red", "green", "blue" }
print(colors[1]) -- the FIRST element
print(colors[#colors]) -- the laststring[] colors = ["red", "green", "blue"];
Console.WriteLine(colors[0]); // the FIRST element
Console.WriteLine(colors[^1]); // the last, counting from the endLua's convention makes
colors[#colors] the natural way to reach the last element; C#'s ^1 index-from-end operator does the same job without the length arithmetic, and colors[colors.Length - 1] is the older spelling you will see everywhere. Both languages throw on an out-of-range index — except that Lua does not: it returns nil.table.insert and table.remove become methods
The operations line up one to one, with the index base shifted by one. Note that Lua overloads
table.insert on its argument count while C# gives the two behaviors separate names.local queue = { "a", "b" }
table.insert(queue, "c")
table.insert(queue, 1, "start")
table.remove(queue, 2)
print(table.concat(queue, ","))var queue = new List<string> { "a", "b" };
queue.Add("c");
queue.Insert(0, "start");
queue.RemoveAt(1);
Console.WriteLine(string.Join(",", queue));The reason
List<T> is the counterpart rather than an array is that C# arrays have a fixed length: there is no Add on int[]. Choosing between them is a decision Lua never asks you to make, and the short answer is that List<T> is what you want unless you know the size up front.A missing key throws instead of returning nil
Two habits break here. Reading a key that is not there is an exception rather than a
nil, and assigning null to a key does not remove it — for an int value it does not even compile.local ages = { alice = 30 }
print(ages.alice)
print(ages.bob) -- nil, not an error
ages.alice = nil -- assigning nil DELETES the key
print(ages.alice)var ages = new Dictionary<string, int> { ["alice"] = 30 };
Console.WriteLine(ages["alice"]);
// ages["bob"] would THROW KeyNotFoundException, so you ask first:
Console.WriteLine(ages.TryGetValue("bob", out var bob) ? bob.ToString() : "absent");
ages.Remove("alice"); // removal is a method call, not an assignment
Console.WriteLine(ages.ContainsKey("alice"));TryGetValue is the pattern to learn: it returns a bool and hands the value back through an out parameter declared inline, which is C#'s way of returning two things without a tuple. Lua's nil-means-absent rule is convenient until an absent key silently propagates; the exception is louder, and on purpose.A nil in the middle ends the array
This is the one row on the page where the two columns print a different number of lines, and that difference is the whole lesson.
local sparse = { "a", "b", nil, "d" }
for index, value in ipairs(sparse) do
print(index, value)
end
print("ipairs stopped at the first nil")string?[] sparse = ["a", "b", null, "d"];
foreach (var value in sparse)
{
Console.WriteLine(value ?? "(null)");
}
Console.WriteLine("foreach visits every slot, nulls included");ipairs walks from 1 until it finds a nil and stops, so it never sees "d". Worse, #sparse is explicitly undefined for a table with a hole — the reference manual permits either 2 or 4 — so a Lua array must have no gaps to be trustworthy. A C# array has a fixed length that counts every slot, and null is just a value living in one of them.pairs becomes foreach, and neither promises an order
A convergence that is easy to miss because both languages hide it: neither
pairs nor a Dictionary guarantees iteration order, and both will happily appear to be ordered until the day the hash layout changes.local ages = { alice = 30, bob = 25, carol = 41 }
local names = {}
for name in pairs(ages) do
names[#names + 1] = name
end
table.sort(names) -- pairs order is UNDEFINED, so sort
for _, name in ipairs(names) do
print(name, ages[name])
endvar ages = new Dictionary<string, int> { ["alice"] = 30, ["bob"] = 25, ["carol"] = 41 };
var names = ages.Keys.ToList();
names.Sort(); // Dictionary order is UNDEFINED too, so sort
foreach (var name in names)
{
Console.WriteLine($"{name}\t{ages[name]}");
}If order matters, say so. C# has
SortedDictionary<K,V> for key order and OrderBy for a one-off, where Lua leaves you to collect and table.sort the keys as above. The Lua column here is the pattern to internalize, because printing a pairs loop directly is how you write a test that passes on your machine and fails elsewhere.table.sort becomes Sort, with a three-way comparison
Lua's comparator answers "does the left one come first?" with a boolean. C#'s answers "which way, and by how much?" with a negative, zero or positive integer, which
CompareTo supplies for anything comparable.local people = {
{ name = "Ada", age = 36 },
{ name = "Bob", age = 25 },
{ name = "Cy", age = 41 },
}
table.sort(people, function(left, right)
return left.age < right.age
end)
for _, person in ipairs(people) do
print(person.name, person.age)
endvar people = new List<(string Name, int Age)>
{
("Ada", 36),
("Bob", 25),
("Cy", 41),
};
people.Sort((left, right) => left.Age.CompareTo(right.Age));
foreach (var person in people)
{
Console.WriteLine($"{person.Name}\t{person.Age}");
}The C# column introduces a tuple —
(string Name, int Age) — which is the lightest way to carry a couple of named fields around, and the closest thing on this page to just using a Lua table. A boolean comparator that is not a strict ordering makes table.sort raise "invalid order function"; C# reports the same class of mistake as an InvalidOperationException.LINQ: The Library Lua Never Had
The loop you always write becomes two calls
Lua has no filter and no map, so this loop — accumulate into a fresh table, conditionally — is the most-written shape in the language. LINQ is the library that replaces it, and it is the biggest single change to how you will write code.
local numbers = { 1, 2, 3, 4, 5, 6 }
local doubledEvens = {}
for _, number in ipairs(numbers) do
if number % 2 == 0 then
doubledEvens[#doubledEvens + 1] = number * 2
end
end
print(table.concat(doubledEvens, ","))int[] numbers = [1, 2, 3, 4, 5, 6];
var doubledEvens = numbers.Where(number => number % 2 == 0)
.Select(number => number * 2);
Console.WriteLine(string.Join(",", doubledEvens));Where filters and Select transforms; the naming comes from SQL rather than from functional programming, which is why they are not called filter and map. Everything in LINQ is an extension method on IEnumerable<T>, so the same two calls work on an array, a List<T>, a Dictionary or a database query.Sum, Max and Average
Every one of these is a loop in Lua, and the loop that computes two of them at once is the one that goes wrong — because the running maximum has to be seeded from the first element rather than from zero.
local prices = { 4.50, 12.00, 3.25 }
local total, highest = 0, prices[1]
for _, price in ipairs(prices) do
total = total + price
if price > highest then highest = price end
end
print(string.format("%.2f\t%d", total, #prices))
print(string.format("%.1f\t%.2f", highest, total / #prices))double[] prices = [4.50, 12.00, 3.25];
Console.WriteLine($"{prices.Sum():F2}\t{prices.Length}");
Console.WriteLine($"{prices.Max():F1}\t{prices.Average():F2}");Traversing the list four times instead of once is real but almost never the bottleneck, and
Aggregate is there for the case where it is. Notice that both columns format their floats explicitly: Lua prints a float as 12.0 and C# prints a double as 12, so a bare print would disagree between the two languages while both were right.GroupBy, which Lua has no answer to
Fifteen lines against three, and the fifteen include the "create the inner table if it is missing" idiom and the "sort the keys because
pairs has no order" idiom. This is the row that makes the case for LINQ on its own.local words = { "apple", "avocado", "banana", "blueberry", "cherry" }
local byLetter = {}
for _, word in ipairs(words) do
local letter = word:sub(1, 1)
byLetter[letter] = byLetter[letter] or {}
table.insert(byLetter[letter], word)
end
local letters = {}
for letter in pairs(byLetter) do
letters[#letters + 1] = letter
end
table.sort(letters)
for _, letter in ipairs(letters) do
print(letter, #byLetter[letter])
endstring[] words = ["apple", "avocado", "banana", "blueberry", "cherry"];
foreach (var group in words.GroupBy(word => word[0]).OrderBy(group => group.Key))
{
Console.WriteLine($"{group.Key}\t{group.Count()}");
}A
GroupBy yields a sequence of groups, each of which is itself a sequence with a Key, so it composes with everything else — OrderBy here, and Select, Sum or Where just as easily. Note also that word[0] gives a char, a type Lua does not have: a one-character Lua string is still a string.A LINQ query is a recipe, not a result
The single most surprising thing about LINQ, and the one that produces bugs rather than compile errors. Compare the outputs before reading on.
local numbers = { 1, 2, 3 }
local squares = {}
for _, number in ipairs(numbers) do
squares[#squares + 1] = number * number
end
numbers[4] = 4 -- too late: squares was already built
print(table.concat(squares, ","))var numbers = new List<int> { 1, 2, 3 };
var squares = numbers.Select(number => number * number);
numbers.Add(4); // not too late: the query has not run yet
Console.WriteLine(string.Join(",", squares));Lua prints
1,4,9 and C# prints 1,4,9,16, because Select stored the transformation and the source, not the answer — the work happens when string.Join enumerates it. Call .ToList() to force it and get Lua's behavior. The flip side is a genuine gain: a query over a million rows that ends in .First() touches one row.First, Any and All
Three questions that each need their own loop-with-a-flag in Lua, and that each read as one word in C#.
local numbers = { 3, 8, 11, 4 }
local firstBig, anyBig, allBig = nil, false, true
for _, number in ipairs(numbers) do
if number > 5 then
anyBig = true
if firstBig == nil then firstBig = number end
else
allBig = false
end
end
print(firstBig, anyBig, allBig)int[] numbers = [3, 8, 11, 4];
Console.WriteLine($"{numbers.First(number => number > 5)}\t" +
$"{numbers.Any(number => number > 5)}\t" +
$"{numbers.All(number => number > 5)}");First throws when nothing matches, which is usually what you want inside code that assumes a match; FirstOrDefault returns null (or 0, for a number) instead and is the closer match to the Lua column's nil. Both Any and All stop as soon as the answer is settled. C# prints its booleans capitalized, as True and False.The SQL-shaped spelling
LINQ has a second syntax, built into the language rather than the library, and it is worth recognizing even if you never write it — it appears throughout Entity Framework code and in anything that reads like a query.
local people = {
{ name = "Ada", city = "London" },
{ name = "Bob", city = "Paris" },
{ name = "Cy", city = "London" },
}
local londoners = {}
for _, person in ipairs(people) do
if person.city == "London" then
londoners[#londoners + 1] = person.name
end
end
table.sort(londoners)
print(table.concat(londoners, ","))var people = new[]
{
(Name: "Ada", City: "London"),
(Name: "Bob", City: "Paris"),
(Name: "Cy", City: "London"),
};
var londoners = from person in people
where person.City == "London"
orderby person.Name
select person.Name;
Console.WriteLine(string.Join(",", londoners));The compiler rewrites this into exactly the
Where().OrderBy().Select() chain from the previous rows, so the two spellings are interchangeable. Query syntax wins when there are joins or several from clauses; method syntax wins everywhere else, and is what most modern C# uses.Functions
Declaring a function
Both languages have a statement form and a value form. The C# signature has to say what goes in and what comes out, and the value form needs a type to be stored in.
local function double(value)
return value * 2
end
local triple = function(value) return value * 3 end
print(double(21))
print(triple(14))int Double(int value)
{
return value * 2;
}
Func<int, int> triple = value => value * 3;
Console.WriteLine(Double(21));
Console.WriteLine(triple(14));Double here is a local function, declared inside the top-level statements — a good match for Lua's local function, and available before the line that declares it. Func<int, int> is a delegate type: the last type parameter is the return type and the ones before it are the parameters, so a two-argument version is Func<int, int, int>.Multiple return values become one tuple
Lua returns a list of values, which the caller may take some or all of. C# returns a single tuple value, which the caller may destructure. The call sites look almost identical; what happens underneath does not.
local function divide(numerator, denominator)
return numerator // denominator, numerator % denominator
end
local quotient, remainder = divide(17, 5)
print(quotient, remainder)(int Quotient, int Remainder) Divide(int numerator, int denominator)
=> (numerator / denominator, numerator % denominator);
var (quotient, remainder) = Divide(17, 5);
Console.WriteLine($"{quotient}\t{remainder}");The difference shows up when a call is used in a larger expression. Lua adjusts a multi-value call to one value in most positions, so
print(divide(17, 5), "x") prints only the quotient — a real source of confusion. A C# tuple is one value everywhere, so nothing silently disappears. Naming the tuple fields, as above, means the caller can also write result.Quotient.... becomes params
Lua's
... is a value list, not an array, so you either pack it into a table with { ... } or interrogate it with select. C#'s params hands you an ordinary array.local function sum(...)
local total = 0
for _, value in ipairs({ ... }) do
total = total + value
end
return total, select("#", ...)
end
local total, count = sum(1, 2, 3, 4)
print(total, count)(int Total, int Count) Sum(params int[] values)
{
var total = 0;
foreach (var value in values) total += value;
return (total, values.Length);
}
var result = Sum(1, 2, 3, 4);
Console.WriteLine($"{result.Total}\t{result.Count}");The reason Lua needs
select("#", ...) at all is that #{ ... } would be wrong when an argument is nil: packing stops the count at the hole, exactly as in the ipairs row above. values.Length has no such problem, because null is a value in the array rather than the end of it.Default and named arguments
Lua has no default arguments; the
or idiom stands in for them, and it is why a Lua signature so often tells you nothing about what is optional. C# puts the defaults in the signature, where callers and tooling can see them.local function connect(host, port, timeout)
port = port or 80
timeout = timeout or 30
return string.format("%s:%d timeout=%d", host, port, timeout)
end
print(connect("example.com"))
print(connect("example.com", 8080))
print(connect("example.com", nil, 5)) -- skip the middle one with nilstring Connect(string host, int port = 80, int timeout = 30)
=> $"{host}:{port} timeout={timeout}";
Console.WriteLine(Connect("example.com"));
Console.WriteLine(Connect("example.com", 8080));
Console.WriteLine(Connect("example.com", timeout: 5)); // skip the middle one by NAMEThe third line is the real gain. Lua's only way to skip a middle argument is to pass
nil positionally, which means callers have to count; a named argument says which parameter it is. The trade-off to know is that a default value is baked into the caller at compile time, so changing one in a shipped library does not reach callers until they rebuild.Closures work the same way
A full convergence, down to the shape of the code: a returned function keeps the variable alive and keeps mutating the same one. Nothing about C#'s static typing changes this.
local function makeCounter()
local count = 0
return function()
count = count + 1
return count
end
end
local counter = makeCounter()
print(counter())
print(counter())
print(counter())Func<int> MakeCounter()
{
var count = 0;
return () =>
{
count++;
return count;
};
}
var counter = MakeCounter();
Console.WriteLine(counter());
Console.WriteLine(counter());
Console.WriteLine(counter());Both languages capture the variable rather than a copy of its value, which is what makes the counter work. C# implements it by moving
count into a compiler-generated class on the heap, and Lua by making it an upvalue; the observable behavior is the same, including that two calls to MakeCounter give two independent counters.Passing a function, now with a signature
Functions are values in both languages, so higher-order code carries over unchanged. What changes is that the C# parameter declares what kind of function it will accept.
local function apply(values, transform)
local results = {}
for index, value in ipairs(values) do
results[index] = transform(value)
end
return results
end
print(table.concat(apply({ 1, 2, 3 }, function(value) return value * 10 end), ","))List<int> Apply(List<int> values, Func<int, int> transform)
{
var results = new List<int>();
foreach (var value in values) results.Add(transform(value));
return results;
}
Console.WriteLine(string.Join(",", Apply([1, 2, 3], value => value * 10)));A Lua function value has no signature at all: passing a two-argument function where a one-argument one was expected is legal, and the extra parameter is simply
nil. C# checks the arity and the types at the call site. Action<T> is the same thing for a function that returns nothing, since Func always has a return type.Metatables Become Classes
The setmetatable idiom becomes a declaration
Lua has no classes. What it has is a convention — a table of methods, an
__index pointing at itself, and a constructor that calls setmetatable — repeated in every codebase, in slightly different forms. C# has the concept in the language.local Account = {}
Account.__index = Account
function Account.new(owner, balance)
return setmetatable({ owner = owner, balance = balance }, Account)
end
function Account:deposit(amount)
self.balance = self.balance + amount
return self.balance
end
local account = Account.new("Ada", 100)
print(account:deposit(50))var account = new Account("Ada", 100);
Console.WriteLine(account.Deposit(50));
class Account(string owner, int balance)
{
public string Owner { get; } = owner;
public int Balance { get; private set; } = balance;
public int Deposit(int amount)
{
Balance += amount;
return Balance;
}
}The declaration buys three things the idiom cannot:
new Account("Ada") with a missing argument is a compile error, Balance can be readable everywhere and writable only inside the class, and the name Account is a type you can use in signatures. The parameter list on the class line is a C# 12 primary constructor, which is the shortest form and the closest in shape to Account.new.self and the colon disappear
Lua's
: is pure sugar: obj:method(a) is obj.method(obj, a), and function T:m() is function T.m(self). The sugar is optional in both directions, which is why calling a method with a dot is a real Lua bug you can write.local Greeter = {}
Greeter.__index = Greeter
function Greeter.new(name)
return setmetatable({ name = name }, Greeter)
end
function Greeter:hello() -- the colon adds an implicit self parameter
return "Hello, " .. self.name
end
function Greeter.goodbye(self) -- exactly the same thing, spelled out
return "Goodbye, " .. self.name
end
local greeter = Greeter.new("Ada")
print(greeter:hello())
print(greeter.goodbye(greeter)) -- dot form: pass the receiver yourselfvar greeter = new Greeter("Ada");
Console.WriteLine(greeter.Hello());
Console.WriteLine(greeter.Goodbye());
class Greeter(string name)
{
// There is one call syntax, and the receiver is never a parameter.
public string Hello() => $"Hello, {name}";
public string Goodbye() => $"Goodbye, {name}";
}In C# there is no dot-versus-colon choice to get wrong, and
this is implicit — you write it only to disambiguate a parameter from a field. Method syntax and receiver-passing are separate concepts here rather than two spellings of one, which removes a whole category of Lua mistake.The __index chain becomes a base class
Lua's inheritance is a metatable on a metatable: a lookup that misses in
Dog is forwarded to Animal. Everything about it is a run-time table walk, which is why the four lines that set it up have to be exactly right and why every framework writes its own class() helper.local Animal = {}
Animal.__index = Animal
function Animal.new(name) return setmetatable({ name = name }, Animal) end
function Animal:speak() return self.name .. " makes a sound" end
local Dog = setmetatable({}, { __index = Animal }) -- Dog falls back to Animal
Dog.__index = Dog
function Dog.new(name) return setmetatable(Animal.new(name), Dog) end
function Dog:speak() return self.name .. " barks" end
print(Animal.new("Generic"):speak())
print(Dog.new("Rex"):speak())Console.WriteLine(new Animal("Generic").Speak());
Console.WriteLine(new Dog("Rex").Speak());
class Animal(string name)
{
protected string Name => name;
public virtual string Speak() => $"{name} makes a sound";
}
class Dog(string name) : Animal(name)
{
public override string Speak() => $"{Name} barks";
}C# makes the relationship a declaration and then demands you be explicit about overriding: the base method must say
virtual and the derived one must say override, or the compiler objects. That is a real constraint compared to Lua, where any method on any table can be replaced at any moment — and it is the reason a C# override cannot be a typo.Computed fields become properties
A field that is really a computation is one of the things Lua needs a metatable for, and it is so routine in C# that it has its own syntax.
local Temperature = {}
Temperature.__index = function(instance, key)
if key == "fahrenheit" then
return instance.celsius * 9 // 5 + 32
end
end
local reading = setmetatable({ celsius = 100 }, Temperature)
print(reading.celsius)
print(reading.fahrenheit) -- computed on access by __indexvar reading = new Temperature { Celsius = 100 };
Console.WriteLine(reading.Celsius);
Console.WriteLine(reading.Fahrenheit); // looks like a field, runs code
class Temperature
{
public int Celsius { get; set; }
public int Fahrenheit => Celsius * 9 / 5 + 32;
}A property is a method pair wearing a field's clothes, and it is why C# code has so few
GetX() methods. { get; set; } generates the storage for you, and => expression makes a read-only computed one. Note the difference in reach: Lua's __index fires for any missing key, so it is one hook for all of them, while a property is declared one at a time.Duck typing becomes an interface
In Lua,
describe works on anything that happens to have those two methods, and finds out at the moment of the call. In C# the requirement is written down as a type, and a class states that it satisfies it.local function describe(shape)
return shape:name() .. " area " .. shape:area()
end
local Square = {}
Square.__index = Square
function Square:name() return "square" end
function Square:area() return self.side * self.side end
print(describe(setmetatable({ side = 3 }, Square)))string Describe(IShape shape) => $"{shape.Name()} area {shape.Area()}";
Console.WriteLine(Describe(new Square { Side = 3 }));
interface IShape
{
string Name();
int Area();
}
class Square : IShape
{
public int Side { get; set; }
public string Name() => "square";
public int Area() => Side * Side;
}The name
IShape follows a .NET convention: interfaces are prefixed with I. C# interfaces are nominal — a class with matching methods that does not name IShape will not be accepted, which is the opposite of Lua's structural check and the opposite of TypeScript's too. The payoff is that the requirement is documented and enforced in one place instead of discovered at the call site.A struct copies on assignment
Every Lua table is a reference, with no exceptions, so aliasing is the only behavior there is. C# has two kinds of type, and for one of them assignment copies the whole value.
local first = { x = 1, y = 2 }
local second = first -- both names refer to the SAME table
second.x = 99
print(first.x)var first = new Point { X = 1, Y = 2 };
var second = first; // a struct is COPIED on assignment
second.X = 99;
Console.WriteLine(first.X);
struct Point
{
public int X;
public int Y;
}Lua prints
99 and C# prints 1. A class is a reference type and behaves like a Lua table; a struct is a value type and is copied on assignment, on being passed to a method, and on being stored in a collection. That last one causes the classic surprise: mutating a struct you pulled out of a List changes the copy, not the item. The usual advice is to make structs small and readonly.Records: value equality and copy-with-changes
Two Lua tables holding the same contents are never equal, because
== on tables compares identity. Writing an equality function by hand for every data-carrying table is the tax, and a C# record is the line that removes it.local origin = { x = 1, y = 2 }
local sameValues = { x = 1, y = 2 }
print(origin == sameValues) -- false: two different tables
local moved = { x = origin.x + 5, y = origin.y } -- copy-with-a-change, by hand
print(moved.x, moved.y)var origin = new Point(1, 2);
var sameValues = new Point(1, 2);
Console.WriteLine(origin == sameValues); // True: records compare by VALUE
var moved = origin with { X = origin.X + 5 }; // copy-with-a-change, built in
Console.WriteLine($"{moved.X}\t{moved.Y}");
Console.WriteLine(origin); // and a readable ToString for free
record Point(int X, int Y);One line generates the constructor, the properties, value-based
Equals and GetHashCode, a ToString that prints the fields, and support for with. Records are immutable by default, so with is how you "change" one — the same discipline as returning a new table rather than mutating the one you were handed. This is the type to reach for whenever a Lua table was just carrying data.What Happened to Metatables
__index becomes an indexer
This is the closest C# gets to
__index, and the gap between them is the most important thing on this page about metatables.local defaults = setmetatable({}, {
__index = function(_, key) return "no value for " .. key end
})
print(defaults.color)
print(defaults["size"])var defaults = new Defaults();
// defaults.Color does not compile: member names are resolved at compile time.
Console.WriteLine(defaults["color"]);
Console.WriteLine(defaults["size"]);
class Defaults
{
public string this[string key] => $"no value for {key}";
}An indexer intercepts
obj["key"] and only that. It cannot intercept obj.Color, because member access is resolved against the type when the program is compiled, not looked up in a table when it runs. So the half of __index that makes dotted access dynamic has no equivalent here — the next-to-last row in this section shows what you use instead.__newindex becomes a missing setter
Making a Lua table read-only takes a proxy table, an
__index that forwards reads, and an __newindex that raises — and it is enforced when someone tries it, at run time.local frozen = setmetatable({}, {
__index = { size = 3 },
__newindex = function() error("read-only", 0) end
})
print(frozen.size)
local ok, message = pcall(function() frozen.size = 9 end)
print(ok, message)var frozen = new Frozen();
Console.WriteLine(frozen.Size);
// frozen.Size = 9; // error CS0200: property has no setter
Console.WriteLine("the compiler refuses before the program runs");
class Frozen
{
public int Size => 3;
}In C# read-only is the absence of a setter, checked when the code is compiled. The distinction matters for how you find out: the Lua version turns a typo into an exception in production, and the C# version turns it into a red squiggle. C# also has
readonly fields and init-only properties, which allow assignment during construction and refuse it afterwards.__add becomes operator +
The one metatable feature that transfers almost intact. Lua names the hooks
__add, __sub, __mul, __eq, __lt, __len, __call, __concat, __tostring; C# spells them operator + and friends.local Vector = {}
Vector.__index = Vector
Vector.__add = function(left, right)
return setmetatable({ x = left.x + right.x, y = left.y + right.y }, Vector)
end
Vector.__tostring = function(self)
return "(" .. self.x .. ", " .. self.y .. ")"
end
local sum = setmetatable({ x = 1, y = 2 }, Vector)
+ setmetatable({ x = 10, y = 20 }, Vector)
print(tostring(sum))var sum = new Vector(1, 2) + new Vector(10, 20);
Console.WriteLine(sum);
readonly struct Vector(int x, int y)
{
public int X => x;
public int Y => y;
public static Vector operator +(Vector left, Vector right)
=> new(left.X + right.X, left.Y + right.Y);
public override string ToString() => $"({X}, {Y})";
}Two differences worth noting. A C# operator is
static and takes both operands, so there is no receiver and no asymmetry to reason about, where Lua picks whichever operand has the metamethod. And __tostring becomes an override of ToString, which every type already has — so Console.WriteLine(sum) finds it without the explicit tostring call the Lua column needs.__eq becomes Equals, GetHashCode and ==
Lua needs one metamethod. C# needs four members, and leaving any of them out produces an object that is equal to another in one context and not in another.
local Money = {}
Money.__index = Money
Money.__eq = function(left, right) return left.cents == right.cents end
local five = setmetatable({ cents = 500 }, Money)
local alsoFive = setmetatable({ cents = 500 }, Money)
print(five == alsoFive) -- true, thanks to __eq
print({ cents = 500 } == { cents = 500 }) -- false: no metatable, so identityvar five = new Money(500);
var alsoFive = new Money(500);
Console.WriteLine(five == alsoFive); // True: operator == is defined
Console.WriteLine(new object() == new object()); // False: identity by default
class Money(int cents)
{
public int Cents => cents;
public override bool Equals(object? other) => other is Money money && money.Cents == Cents;
public override int GetHashCode() => Cents;
public static bool operator ==(Money left, Money right) => left.Equals(right);
public static bool operator !=(Money left, Money right) => !left.Equals(right);
}The reason for four is that
Equals is what collections call, GetHashCode is what a Dictionary or HashSet buckets by, and == is what your code writes — so an object with Equals but no GetHashCode gets lost in a hash set. That is a lot of boilerplate to get right, which is exactly why record exists and why it is the right answer nearly every time.Dynamic member access, when you really need it
The metatable trick that makes an object answer to any method name — the basis of every Lua proxy, mock and remote-call stub — does have a C# counterpart, and it is deliberately out of the way.
local recorder = setmetatable({}, {
__index = function(_, name)
return function(...)
return name .. " called with " .. select("#", ...) .. " args"
end
end
})
print(recorder.anything(1, 2, 3))using System.Dynamic;
dynamic recorder = new Recorder();
Console.WriteLine(recorder.Anything(1, 2, 3));
class Recorder : DynamicObject
{
public override bool TryInvokeMember(InvokeMemberBinder binder, object?[]? args, out object? result)
{
result = $"{binder.Name} called with {args?.Length ?? 0} args";
return true;
}
}Declaring a variable
dynamic switches member resolution from compile time to run time, and DynamicObject lets you intercept it. In exchange you lose everything static typing was buying: no completion, no compile-time checking, and a slower call. It is the right tool for talking to JSON, COM or a scripting host — and the wrong one for the ordinary object design where Lua would reach for __index without a second thought.Pattern Matching
The elseif ladder becomes a switch expression
Lua has no
switch at all — the usual replacements are an elseif ladder or a table of functions keyed by value. C#'s version is an expression, so it produces a value rather than jumping around.local function describe(value)
if value == 0 then return "zero"
elseif value == 1 then return "one"
elseif value < 0 then return "negative"
else return "many" end
end
print(describe(0))
print(describe(-4))
print(describe(7))string Describe(int value) => value switch
{
0 => "zero",
1 => "one",
< 0 => "negative",
_ => "many",
};
Console.WriteLine(Describe(0));
Console.WriteLine(Describe(-4));
Console.WriteLine(Describe(7));Three things are checked for you. The arms must all produce the same type;
_ is the catch-all and the compiler warns when there is none and the cases are not exhaustive; and there is no fall-through, so no break to forget. The arms are patterns, not just values — < 0 above is a relational pattern — which is what the next three rows build on.type() checks become type patterns
Lua's
type() hands back a string that you compare, and then you still have the original value with no more information about it than before. A C# type pattern tests and binds in one step.local function label(value)
local kind = type(value)
if kind == "number" then return "number " .. value end
if kind == "string" then return "string of " .. #value end
if kind == "table" then return "table of " .. #value end
return kind
end
print(label(42))
print(label("hello"))
print(label({ 1, 2 }))string Label(object value) => value switch
{
int number => $"number {number}",
string text => $"string of {text.Length}",
int[] numbers => $"array of {numbers.Length}",
_ => value.GetType().Name,
};
Console.WriteLine(Label(42));
Console.WriteLine(Label("hello"));
Console.WriteLine(Label(new[] { 1, 2 }));Inside the
string text arm, text is a string, so text.Length is checked at compile time — where the Lua column's #value is only correct because the branch above it happened to be right. The same test outside a switch is written if (value is string text), which is the form you will see most often.Matching on the shape of a value
A property pattern reaches inside the value and matches on its members, so a decision table stays a table instead of turning into nested conditions.
local function shipping(order)
if order.weight > 20 and order.express then return "freight express" end
if order.weight > 20 then return "freight" end
if order.express then return "parcel express" end
return "parcel"
end
print(shipping({ weight = 30, express = true }))
print(shipping({ weight = 2, express = false }))string Shipping(Order order) => order switch
{
{ Weight: > 20, Express: true } => "freight express",
{ Weight: > 20 } => "freight",
{ Express: true } => "parcel express",
_ => "parcel",
};
Console.WriteLine(Shipping(new Order(30, true)));
Console.WriteLine(Shipping(new Order(2, false)));
record Order(int Weight, bool Express);The arms are tried in order, so the more specific ones must come first — exactly as with the Lua ladder, and exactly as easy to get wrong. What you gain is that the shape being tested is visible at a glance rather than spread across boolean operators, and that patterns nest:
{ Customer: { Country: "GB" } } is a valid arm.Matching a sequence by its shape
C# 11 added patterns for sequences, so the "empty, one, or a head and a tail" shape that every Lisp and every functional language writes directly is now writable directly here too.
local function head(values)
if #values == 0 then return "empty" end
if #values == 1 then return "just " .. values[1] end
return values[1] .. " then " .. (#values - 1) .. " more"
end
print(head({}))
print(head({ "a" }))
print(head({ "a", "b", "c" }))string Head(string[] values) => values switch
{
[] => "empty",
[var only] => $"just {only}",
[var first, .. var rest] => $"{first} then {rest.Length} more",
};
Console.WriteLine(Head([]));
Console.WriteLine(Head(["a"]));
Console.WriteLine(Head(["a", "b", "c"]));.. is the slice pattern and matches any number of elements, so [var first, .. var rest] destructures a head and a tail. Nothing in Lua corresponds: the length has to be measured and the elements indexed by hand, and — as the ipairs row showed — #values is not even reliable if the table has a hole. Note that the C# version needs no catch-all arm, because the three patterns are exhaustive and the compiler knows it.pcall Becomes try/catch
pcall becomes try/catch
Lua's error handling is a function you wrap around a function:
pcall returns a success flag first and the error second, which is why the two-variable assignment is so recognizable. C# has the statement form the rest of the industry uses.local ok, message = pcall(function()
error("something went wrong", 0)
end)
if not ok then print(message) endtry
{
throw new InvalidOperationException("something went wrong");
}
catch (Exception error)
{
Console.WriteLine(error.Message);
}The structural difference is what happens when you forget. An unchecked
error() in Lua propagates to the host and usually prints a traceback; an uncaught C# exception terminates the process with a stack trace and a non-zero exit code. Neither language checks at compile time that you handled anything — C# has no checked exceptions, unlike Java.error() can throw anything; throw needs an Exception
Lua's
error takes any value — a string, a number, a table — and hands it back untouched, which is how Lua libraries carry structured error information. C# requires that the thing thrown derive from Exception.local ok, problem = pcall(function()
error({ code = 404, detail = "missing" }) -- any value at all
end)
print(problem.code, problem.detail)try
{
throw new NotFoundException(404, "missing"); // only Exceptions can be thrown
}
catch (NotFoundException problem)
{
Console.WriteLine($"{problem.Code}\t{problem.Detail}");
}
class NotFoundException(int code, string detail) : Exception(detail)
{
public int Code => code;
public string Detail => detail;
}The constraint is what makes
catch by type possible, and it comes with machinery for free: a message, an InnerException for wrapping, and a captured stack trace. Custom exception classes are cheap — the one above is three lines — and the convention is to end the name in Exception.Catching by type instead of inspecting
Because a Lua error can be any value, the handler has to work out what it received before it can act — and every library chooses its own shape. C# dispatches on the type, so the sorting is done by the language.
local function risky(kind)
if kind == "number" then error("bad number", 0) end
error({ kind = kind }, 0)
end
local ok, problem = pcall(risky, "number")
if type(problem) == "string" then
print("string error: " .. problem)
else
print("table error: " .. problem.kind)
endvoid Risky(string kind)
{
if (kind == "number") throw new FormatException("bad number");
throw new InvalidOperationException(kind);
}
try
{
Risky("number");
}
catch (FormatException error)
{
Console.WriteLine($"format error: {error.Message}");
}
catch (InvalidOperationException error)
{
Console.WriteLine($"state error: {error.Message}");
}Handlers are tried top to bottom, so the most specific type goes first: a
catch (Exception) above a catch (FormatException) is a compile error, which is a nice touch. catch (SomeException error) when (error.Code == 404) adds a filter, and a bare catch with no type catches everything — the equivalent of not checking what pcall handed you.finally, which Lua 5.3 does not have
Lua 5.3 has no
finally and no scope-exit hook, so cleanup is written after a pcall and duplicated at every early return. Lua 5.4 added the <close> attribute for this, but Fengari implements 5.3, so it is not available in the browser.local resource = "open"
local ok, message = pcall(function()
error("failed midway", 0)
end)
if not ok then print(message) end
resource = "closed" -- cleanup, run whatever happened
print(resource)var resource = "open";
try
{
throw new Exception("failed midway");
}
catch (Exception error)
{
Console.WriteLine(error.Message);
}
finally
{
resource = "closed"; // runs whatever happened
}
Console.WriteLine(resource);finally runs on the way out however you leave — normal completion, an exception, or a return from inside the try. That last case is the one worth internalizing, because it is precisely the path the Lua idiom above skips: a return before the cleanup line simply never reaches it.using: cleanup attached to the resource
Lua's answer to "make sure this is released" is the higher-order wrapper above — a function that brackets a body, which is the shape of
io.open-style helpers everywhere. C# attaches the cleanup to the resource's own type instead.local function withLog(body)
print("open")
local ok, message = pcall(body)
print("close")
if not ok then print("error: " .. message) end
end
withLog(function() print("working") end)
withLog(function() error("boom", 0) end)void WithLog(Action body)
{
using var log = new Log(); // Dispose() runs when the scope ends
body();
}
WithLog(() => Console.WriteLine("working"));
try { WithLog(() => throw new Exception("boom")); }
catch (Exception error) { Console.WriteLine($"error: {error.Message}"); }
class Log : IDisposable
{
public Log() => Console.WriteLine("open");
public void Dispose() => Console.WriteLine("close");
}A type that implements
IDisposable can be declared with using, and Dispose() is called when the enclosing scope ends, including via an exception — which is why both columns print close before error:. The win over the wrapper is that the discipline lives with the resource, so every caller gets it without knowing to ask; await using is the asynchronous version.Coroutines Become Two Things
coroutine.wrap becomes yield return
Lua's coroutines split into two different C# features, and this is the first: a function that produces values one at a time and suspends in between. It is the same machinery, which is why the code lines up so closely.
local function counter(limit)
return coroutine.wrap(function()
for value = 1, limit do
coroutine.yield(value)
end
end)
end
for value in counter(3) do print(value) endIEnumerable<int> Counter(int limit)
{
for (var value = 1; value <= limit; value++)
{
yield return value;
}
}
foreach (var value in Counter(3)) Console.WriteLine(value);A method containing
yield return is compiled into a state machine that remembers where it stopped — a coroutine, generated for you. coroutine.wrap is the closer of Lua's two spellings because it returns a callable that for can drive, exactly as IEnumerable is what foreach drives. Both are lazy: nothing runs until the loop asks.Sending values IN, which C# cannot do
Here is the one place on this page where Lua is strictly more capable.
coroutine.yield is an expression: it hands a value out, and the value passed to the next resume becomes its result, so a coroutine is a two-way conversation.local machine = coroutine.create(function(first)
print("got " .. first)
local second = coroutine.yield("ready")
print("got " .. second)
return "done"
end)
local _, reply = coroutine.resume(machine, "one")
print("yielded " .. reply)
print(select(2, coroutine.resume(machine, "two")))var machine = new Machine();
Console.WriteLine($"yielded {machine.Start("one")}");
Console.WriteLine(machine.Resume("two"));
class Machine
{
public string Start(string first)
{
Console.WriteLine($"got {first}");
return "ready";
}
public string Resume(string second)
{
Console.WriteLine($"got {second}");
return "done";
}
}C#'s
yield return is one-way — there is no way to send a value into a suspended iterator. When you need the conversation you write the state as an object, as above, or use a System.Threading.Channels.Channel<T> to pass values in both directions. Neither is as direct, and it is worth knowing this is a real loss rather than a spelling you have not found yet.Lua ships coroutines; C# ships a scheduler
The second thing Lua's coroutines become. Lua gives you suspension and nothing else: something has to decide when to resume, and that something is a scheduler you write or import (copas, luv, or the game engine's update loop). C# has the scheduler in the runtime.
local function fetch(label)
return coroutine.wrap(function()
coroutine.yield() -- stands in for waiting on something
return label .. " done"
end)
end
local a, b = fetch("A"), fetch("B")
a(); b() -- start both
print(a())
print(b())async Task<string> FetchAsync(string label)
{
await Task.Delay(10); // suspends without holding a thread
return $"{label} done";
}
Console.WriteLine(await FetchAsync("A"));
Console.WriteLine(await FetchAsync("B"));await suspends the method, returns the thread to the pool, and resumes where it left off when the awaited operation completes. So the mechanism is a coroutine, but the decision about when to resume belongs to the runtime rather than to you. The name convention is real: a method returning Task is written with an Async suffix so callers know to await it.Waiting for several things at once
Running several suspended things and collecting their results is where writing your own scheduler stops being fun. The Lua column is the minimum honest version: resume each job until it produces something.
local function work(label, steps)
return coroutine.wrap(function()
for _ = 1, steps do coroutine.yield() end
return label
end)
end
local jobs = { work("first", 1), work("second", 2) }
local results = {}
for _, job in ipairs(jobs) do
local result = job()
while result == nil do result = job() end -- drive it yourself
results[#results + 1] = result
end
print(table.concat(results, ", "))async Task<string> WorkAsync(string label, int milliseconds)
{
await Task.Delay(milliseconds);
return label;
}
var results = await Task.WhenAll(WorkAsync("first", 20), WorkAsync("second", 10));
Console.WriteLine(string.Join(", ", results));Task.WhenAll returns an array in argument order regardless of which finished first, which is why both columns print first, second even though the C# tasks complete the other way round. Task.WhenAny gives you the first to finish, and a CancellationToken passed through the chain is how you stop the rest — none of which has a Lua counterpart short of building it.A sequence that awaits between items
A Lua coroutine does not care why it suspended, so one mechanism covers both producing values and waiting for something. C# split those into iterators and tasks, and then needed a third thing to put them back together.
local function pages(count)
return coroutine.wrap(function()
for page = 1, count do
coroutine.yield("page " .. page)
end
end)
end
for page in pages(3) do print(page) endasync IAsyncEnumerable<string> PagesAsync(int count)
{
for (var page = 1; page <= count; page++)
{
await Task.Delay(1);
yield return $"page {page}";
}
}
await foreach (var page in PagesAsync(3)) Console.WriteLine(page);IAsyncEnumerable<T> with await foreach is a sequence whose items arrive over time — paginated API responses, rows streaming out of a query, lines from a socket. The Lua column is the same shape written with the one construct Lua has, which is a fair summary of the whole section: Lua's coroutines are more general, and C#'s three specialized tools each do more for you.Unity resumes an IEnumerator once per frame
This is the row that makes the move to Unity feel less like a change of language, because the pattern is one you already use: a coroutine per behavior, resumed once per frame by the host.
local function fadeOut(frames)
return coroutine.wrap(function()
for frame = 1, frames do
print("alpha " .. string.format("%.1f", 1 - frame / frames))
coroutine.yield()
end
end)
end
local fade = fadeOut(3)
for _ = 1, 3 do fade() end -- the host's update loop, in miniatureusing System.Collections;
using UnityEngine;
public class Fader : MonoBehaviour
{
void Start() => StartCoroutine(FadeOut(3));
IEnumerator FadeOut(int frames)
{
for (var frame = 1; frame <= frames; frame++)
{
Debug.Log($"alpha {1f - (float)frame / frames:F1}");
yield return null; // "resume me next frame"
}
}
}Unity's
StartCoroutine takes an IEnumerator and calls MoveNext() on it each frame, so yield return null means "next frame" and yield return new WaitForSeconds(2) means "in two seconds" — the same shape as a LÖVE or Defold scheduler resuming your coroutines from update. Note that these are iterators, not Tasks: Unity coroutines predate async/await and are still the idiom for frame-paced game logic.require Becomes using and NuGet
require loads a file; using imports a namespace
The mental model changes rather than the syntax. Lua's
require is a file operation: it searches package.path, runs the file once, caches the result in package.loaded, and returns it — so a module is a value you assign to a variable.-- require runs a FILE once and gives you whatever it returned.
-- In a real project: local mathHelpers = require("math_helpers")
local mathHelpers = { double = function(value) return value * 2 end }
print(mathHelpers.double(21))// 'using' imports a NAMESPACE. Files and namespaces are unrelated.
using MathHelpers;
Console.WriteLine(Doubling.Double(21));
namespace MathHelpers
{
public static class Doubling
{
public static int Double(int value) => value * 2;
}
}A C#
using imports no code and returns nothing; it tells the compiler which namespaces to search for unqualified names, and the code itself has already been compiled into the assembly. One file can declare several namespaces and one namespace can span a hundred files, so the directory layout is a convention rather than a mechanism. Modern code writes namespace MathHelpers; once at the top of a file instead of using braces.local becomes private, public and internal
Lua has exactly one privacy mechanism: a
local is visible in its file and nowhere else, and anything you want exported goes on a table you return. That is enough surprisingly often.local module = {}
local function helper(value) -- 'local', so invisible outside this file
return value + 1
end
function module.increment(value)
return helper(value)
end
print(module.increment(41))Console.WriteLine(Counter.Increment(41));
static class Counter
{
private static int Helper(int value) => value + 1; // private to the type
public static int Increment(int value) => Helper(value);
}C# has four levels, and the one with no Lua counterpart is the interesting one:
internal means "visible anywhere in this assembly", which is a unit of packaging Lua does not have. It is also the default for a type — the static class Counter above is internal — so a type is shared within your project and hidden from anyone referencing it until you say public.LuaRocks becomes NuGet
Both ecosystems have a registry and a command-line installer, and the difference is where the dependency is recorded.
-- $ luarocks install penlight
-- LuaRocks installs into a tree that package.path already searches.
local stringx = require("pl.stringx")
print(stringx.strip(" padded "))// $ dotnet add package Humanizer
// The reference is recorded in the .csproj, and restored on build.
using Humanizer;
Console.WriteLine("someLongName".Humanize());LuaRocks installs into a shared tree and the interpreter finds it through
package.path, so which version you get depends on the machine unless you add a lockfile tool. dotnet add package writes the reference into the project file, which is checked in, and dotnet restore reproduces it — so the dependency set travels with the code. Neither column runs here, because neither package is installed in the browser runner.Patching the string metatable becomes an extension method
Adding a method to a type you do not own is routine in Lua — every string has the same metatable, so one assignment reaches all of them. C# has a way to do this too, and it is much narrower on purpose.
-- Lua lets you add to the string metatable, for the whole program.
local stringMethods = getmetatable("").__index
stringMethods.shout = function(self) return self:upper() .. "!" end
print(("hello"):shout())Console.WriteLine("hello".Shout());
static class StringExtensions
{
public static string Shout(this string text) => text.ToUpper() + "!";
}The
this on the first parameter is what makes it an extension method: the compiler rewrites "hello".Shout() into StringExtensions.Shout("hello"), so nothing is added to string at all. Two consequences follow. It is scoped — only files that import the containing namespace see it — and it cannot break anything, because it loses to any real method of the same name. The Lua version mutates a table every string in the program shares, which is why libraries are told not to.Gotchas for Lua Developers
Building a string in a loop, the same fix twice
Because strings are immutable in both languages,
result = result .. piece in a loop allocates a new string every turn in both. The knowledge transfers; only the tool's name changes.local pieces = {}
for index = 1, 5 do
pieces[#pieces + 1] = tostring(index)
end
print(table.concat(pieces)) -- accumulate, then join oncevar pieces = new StringBuilder();
for (var index = 1; index <= 5; index++)
{
pieces.Append(index);
}
Console.WriteLine(pieces); // accumulate, then read oncetable.concat over a table of pieces and StringBuilder are the same technique: one buffer, filled once. C# additionally optimizes concatenation of a fixed number of operands into a single call, so a + b + c in one expression is fine — it is the loop that is not.Capturing the loop variable
Both languages capture variables rather than values, so what matters is how many variables the loop creates. They disagree, and the disagreement is silent.
local callbacks = {}
for index = 1, 3 do
callbacks[index] = function() return index end -- a FRESH index each turn
end
print(callbacks[1]())
print(callbacks[3]())var callbacks = new List<Func<int>>();
for (var index = 1; index <= 3; index++)
{
var captured = index; // the loop variable is ONE variable
callbacks.Add(() => captured);
}
Console.WriteLine(callbacks[0]());
Console.WriteLine(callbacks[2]());Lua's numeric
for creates a new control variable each iteration, so each closure gets its own and the code above needs no help. C#'s for has one index for the whole loop, so without the copy every closure would return 4. foreach is the exception — it was changed in C# 5 to create a fresh variable per iteration, precisely because this bug was so common — so the copy is only needed in a for.What == compares
A convergence with a sharp edge. Both languages special-case strings and compare everything else by identity, so the rule you already know carries over — including the part where two empty containers are not equal.
local first = "hello"
local second = "hel" .. "lo"
print(first == second) -- true: strings compare by content
local left, right = {}, {}
print(left == right) -- false: tables compare by identityvar first = "hello";
var second = "hel" + "lo";
Console.WriteLine(first == second); // True: string == compares content
var left = new List<int>();
var right = new List<int>();
Console.WriteLine(left == right); // False: reference identityIn C# the special case is
operator == defined on string, and a type can define its own — which is exactly what record does, and why a record compares by value while a class does not. When you want to compare contents rather than identity, left.SequenceEqual(right) is the LINQ answer; Lua has no equivalent and you write the loop.Chained and becomes ?.
Reaching into a structure that might not be there is the same problem in both languages, and both have an operator for it that stops at the first absence.
local config = { server = { host = "localhost" } }
print(config.server and config.server.host)
print(config.database and config.database.host) -- nil, and no errorvar config = new Config(new Server("localhost"), null);
Console.WriteLine(config.Server?.Host);
Console.WriteLine(config.Database?.Host ?? "(none)"); // ?. short-circuits
record Server(string Host);
record Config(Server? Server, Server? Database);?. returns null instead of throwing when the left side is null, and the rest of the chain is skipped — so a?.b.c is safe even though only the first link has the question mark. One printing difference to know: Lua's print(nil) writes nil, while Console.WriteLine(null) writes an empty line, which is why the ?? is there to make the absence visible.Wrong argument counts stop being silent
Lua is entirely relaxed about arity: too few arguments become
nil, too many are dropped, and neither is reported. This is a genuine convenience and a genuine source of bugs that survive to production.local function greet(name, greeting)
return (greeting or "Hello") .. ", " .. (name or "world")
end
print(greet()) -- missing arguments are simply nil
print(greet("Ada"))
print(greet("Ada", "Hi", "extra")) -- extra arguments are discardedstring Greet(string name = "world", string greeting = "Hello")
=> $"{greeting}, {name}";
Console.WriteLine(Greet());
Console.WriteLine(Greet("Ada"));
// Greet("Ada", "Hi", "extra"); // error CS1501: no overload takes 3 arguments
Console.WriteLine(Greet("Ada", "Hi"));C# checks the count and the types at the call site, so both mistakes are compile errors — unless the parameter has a default, in which case omitting it is deliberate. The habit worth unlearning is Lua's
value or default at the top of a function: in C# that information belongs in the signature, where the caller can see it.