PONYλM2Modula-2

Visual Basic.CodeCompared.To/Pascal

An interactive executable cheatsheet comparing Visual Basic and Pascal

Visual Basic (.NET 10) Free Pascal 3.2.2
Output & Running
Hello, World
Of every target on this anchor, this is the one that will look most like home.
Option Strict On Imports System Module HelloWorld Sub Main() Console.WriteLine("Hello, World!") End Sub End Module
program HelloWorld; begin WriteLn('Hello, World!'); end.
Module becomes program, the body sits between begin and end, and the program ends with end. — a full stop, not a semicolon. Console.WriteLine becomes WriteLn, built in with nothing to import. Strings use single quotes. Both languages descend from the same family of readable, keyword-delimited syntax, and it shows immediately.
Formatted output
No interpolated string — but two other ways to do the job, one of which is unique to Pascal.
Option Strict On Imports System Module FormatDemo Sub Main() Dim name As String = "Ada" Dim score As Integer = 42 Dim ratio As Double = 0.8756 Console.WriteLine($"Hello, {name}! Score: {score}") Console.WriteLine($"Rounded: {ratio:F2}") End Sub End Module
program FormatDemo; uses SysUtils; var Name: string; Score: Integer; Ratio: Double; begin Name := 'Ada'; Score := 42; Ratio := 0.8756; WriteLn('Hello, ', Name, '! Score: ', Score); WriteLn('Rounded: ', Ratio:0:2); WriteLn(Format('%s scored %d', [Name, Score])); end.
There is no $"...". WriteLn takes any number of arguments of any type and prints them in order, which covers most cases. The unusual one is the write specifier: Ratio:0:2 means "minimum width 0, two decimal places", replacing {ratio:F2}. Format from SysUtils is String.Format, using C-style %s and %d with the arguments in a bracketed array.
Case does not matter here either
The adjustment every other target on this anchor demands is the one adjustment you do not have to make here.
Option Strict On Imports System Module CaseDemo Sub Main() Dim customerName As String = "Grace" ' All one variable, whatever case you type Console.WriteLine(customerName) Console.WriteLine(CustomerName) Console.WriteLine(CUSTOMERNAME) End Sub End Module
program CaseDemo; var CustomerName: string; begin CustomerName := 'Grace'; { All one variable — Pascal is case-insensitive too } WriteLn(customername); WriteLn(CustomerName); WriteLn(CUSTOMERNAME); end.
Pascal identifiers are case-insensitive, exactly as Visual Basic's are, so customerName and CustomerName are one name and the compiler does not care which you type. That removes the single most dangerous silent difference on every other page under this anchor. Convention is PascalCase for almost everything, with a T prefix on type names (TPerson) and an F prefix on private class fields (FName).
Program Structure
Declarations come first, in their own blocks
Every declaration is hoisted to the top of its scope, in labelled sections — this is the biggest structural change on the page.
Option Strict On Imports System Module StructureDemo Const Greeting As String = "Hello" Sub Main() Dim count As Integer = 3 Console.WriteLine(Greeting) ' A variable may be declared anywhere Dim label As String = "widget" Console.WriteLine($"{count} {label}") End Sub End Module
program StructureDemo; const Greeting = 'Hello'; type TCount = Integer; var Count: TCount; Label_: string; begin Count := 3; WriteLn(Greeting); Label_ := 'widget'; WriteLn(Count, ' ', Label_); end.
A Pascal routine declares its constants, types and variables in const, type and var blocks before the begin, never interleaved with statements. So a Dim written halfway down a Sub has to move up. It is more rigid than Dim-anywhere and has one real benefit: every name a routine uses is visible in one place at the top. The type block is where you name your own types, which the collections section uses heavily.
begin and end replace every End keyword
The keyword is different and the semicolon rule is genuinely fiddly — read where they are and are not.
Option Strict On Imports System Module BlockDemo Sub Main() Dim temperature As Integer = 30 If temperature > 25 Then Console.WriteLine("Warm") Console.WriteLine("Very warm") ElseIf temperature > 10 Then Console.WriteLine("Mild") Else Console.WriteLine("Cold") End If End Sub End Module
program BlockDemo; var Temperature: Integer; begin Temperature := 30; if Temperature > 25 then begin WriteLn('Warm'); WriteLn('Very warm'); end else if Temperature > 10 then WriteLn('Mild') { no semicolon before else } else WriteLn('Cold'); end.
End If, End Sub and End Module all become end, and a block of more than one statement must be wrapped in begin ... end — a single statement needs neither. The rule that catches everyone: a semicolon is a separator, not a terminator, so there is no semicolon before else. ElseIf becomes two words. Then survives in lower case.
Comments
Three comment forms, and Pascal is the rare language with two block forms.
Option Strict On Imports System Module CommentDemo ''' <summary>Doubles a number.</summary> Function Twice(value As Integer) As Integer ' A line comment Return value * 2 End Function Sub Main() Console.WriteLine(Twice(21)) End Sub End Module
program CommentDemo; { A brace comment, which may span lines } (* An older form, equally valid *) // A line comment function Twice(Value: Integer): Integer; begin Result := Value * 2; end; begin WriteLn(Twice(21)); end.
' becomes //. Both { ... } and (* ... *) are block comments — the second exists for keyboards that lacked braces, and both are still accepted. Because {$...} is a compiler directive rather than a comment, a brace comment starting with $ means something: {$mode objfpc} is how a file selects its dialect. There is no XML documentation convention; Lazarus reads the comment immediately above a declaration.
Variables & Types
:= assigns, = compares
Comparison keeps the symbol you already use — it is assignment that changes.
Option Strict On Imports System Module AssignDemo Sub Main() Dim left As Integer = 5 Dim right As Integer = 5 ' One symbol, two jobs If left = right Then Console.WriteLine("equal") If left <> 6 Then Console.WriteLine("not six") left = left + 1 Console.WriteLine(left) End Sub End Module
program AssignDemo; var Left, Right: Integer; begin Left := 5; Right := 5; if Left = Right then WriteLn('equal'); if Left <> 6 then WriteLn('not six'); Left := Left + 1; WriteLn(Left); end.
= means equality and nothing else, and <> is inequality — both exactly as you write them today, and a relief after C#'s ==/!=. Assignment is :=, which is where Pascal took the ambiguity out. Writing Left = 6 as a statement is a compile error rather than a silent assignment, which is the same protection C# gets from requiring a bool condition.
The built-in types
Nearly a rename exercise, and the money type is one of the few outside .NET that exists at all.
Option Strict On Imports System Module TypeDemo Sub Main() Dim small As Short = 1S Dim whole As Integer = 2 Dim big As Long = 3L Dim precise As Double = 6.7 Dim exact As Decimal = 8.9D Dim letter As Char = "A"c Dim flag As Boolean = True Console.WriteLine($"{small} {whole} {big} {precise} {exact} {letter} {flag}") End Sub End Module
program TypeDemo; var Small: SmallInt; Whole: Integer; Big: Int64; Precise: Double; Exact: Currency; Letter: Char; Flag: Boolean; begin Small := 1; Whole := 2; Big := 3; Precise := 6.7; Exact := 8.9; Letter := 'A'; Flag := True; WriteLn(Small, ' ', Whole, ' ', Big, ' ', Precise:0:1, ' ', Exact:0:2, ' ', Letter, ' ', Flag); end.
ShortSmallInt, IntegerInteger, LongInt64, DoubleDouble, SingleSingle, BooleanBoolean, CharChar with single quotes. The notable one is Currency: a fixed-point type with four decimal places, stored as a scaled 64-bit integer — not identical to Decimal (fewer digits, fixed rather than floating scale) but a real money type, which Go, Rust, JavaScript and Java all lack.
Strings, and their 1-based index
If you still write Mid and InStr, this column will feel like coming home.
Option Strict On Imports System Module StringDemo Sub Main() Dim text As String = "Visual Basic" Console.WriteLine(text.Length) Console.WriteLine(text.ToUpper()) Console.WriteLine(text.Substring(0, 6)) Console.WriteLine(Mid(text, 1, 6)) Console.WriteLine(text.IndexOf("Basic")) Console.WriteLine(InStr(text, "Basic")) End Sub End Module
program StringDemo; uses SysUtils; var Text: string; begin Text := 'Visual Basic'; WriteLn(Length(Text)); WriteLn(UpperCase(Text)); WriteLn(Copy(Text, 1, 6)); WriteLn(Pos('Basic', Text)); WriteLn(Text[1]); WriteLn(Text + ' — concatenated'); end.
Pascal strings are 1-based, like the Microsoft.VisualBasic string functions and unlike Substring. Mid(text, 1, 6) becomes Copy(Text, 1, 6) with the same numbers; InStr becomes Pos, also 1-based and also returning 0 when not found; Len becomes Length, UCase becomes UpperCase. Text[1] indexes a character directly. Concatenation is +, and strings are managed and reference-counted, so no manual freeing is needed for them.
Converting between types
The conversion family maps almost one to one — including the TryParse pattern, which most targets on this anchor lack.
Option Strict On Imports System Module ConversionDemo Sub Main() Dim text As String = "123" Dim parsed As Integer = CInt(text) Dim asText As String = CStr(parsed * 2) Dim value As Integer If Integer.TryParse("12x", value) Then Console.WriteLine(value) Else Console.WriteLine("not a number") End If Console.WriteLine($"{parsed} {asText}") End Sub End Module
program ConversionDemo; uses SysUtils; var Text, AsText: string; Parsed, Value: Integer; begin Text := '123'; Parsed := StrToInt(Text); AsText := IntToStr(Parsed * 2); if TryStrToInt('12x', Value) then WriteLn(Value) else WriteLn('not a number'); WriteLn(Parsed, ' ', AsText); WriteLn(StrToIntDef('12x', -1)); end.
CIntStrToInt, CStrIntToStr, CDblStrToFloat, CBoolStrToBool, all from SysUtils. Integer.TryParse becomes TryStrToInt, with the same shape: a Boolean result and an output parameter. There is also StrToIntDef, which takes a fallback and needs no output parameter at all. Of every target on this anchor this is the closest correspondence, because both languages evolved the same idea independently.
Arrays & Records
Arrays choose their own bounds
The array declares its own first and last index — which is what Option Base 1 was reaching for.
Option Strict On Imports System Module ArrayDemo Sub Main() ' The number is the UPPER BOUND — six slots, 0 to 5 Dim scores(5) As Integer scores(1) = 10 scores(5) = 50 Console.WriteLine(scores.Length) Console.WriteLine(LBound(scores)) Console.WriteLine(UBound(scores)) Console.WriteLine(scores(1)) End Sub End Module
program ArrayDemo; type TScores = array[1..5] of Integer; { 1-based, by choice } var Scores: TScores; Index: Integer; begin for Index := Low(Scores) to High(Scores) do Scores[Index] := Index * 10; WriteLn(Length(Scores)); WriteLn(Low(Scores)); WriteLn(High(Scores)); WriteLn(Scores[1]); end.
array[1..5] means indices 1 through 5, and you may pick any bounds at all: array[1900..2100] indexed by year is idiomatic Pascal. LBound and UBound become Low and High, and using them rather than literals is the convention, so a bound change never breaks a loop. Anyone still missing Option Base 1 from VB6 has it back — properly, per array, rather than per file.
Dynamic arrays replace List(Of T)
A dynamic array grows — and note that it is 0-based where a fixed array was whatever you declared.
Option Strict On Imports System Imports System.Collections.Generic Module DynamicDemo Sub Main() Dim fruits As New List(Of String) From {"apple", "banana"} fruits.Add("cherry") Console.WriteLine(fruits.Count) Console.WriteLine(fruits(0)) Console.WriteLine(String.Join(", ", fruits)) End Sub End Module
program DynamicDemo; var Fruits: array of string; Index: Integer; begin Fruits := ['apple', 'banana']; { dynamic array literal } SetLength(Fruits, Length(Fruits) + 1); Fruits[High(Fruits)] := 'cherry'; WriteLn(Length(Fruits)); WriteLn(Fruits[0]); { dynamic arrays ARE 0-based } for Index := Low(Fruits) to High(Fruits) do Write(Fruits[Index], ' '); WriteLn; end.
array of T with no bounds is a dynamic array, sized by SetLength and always starting at 0. That inconsistency with fixed arrays is a genuine wart, and Low/High is why it rarely bites. There is no Add: growing means SetLength then assigning, or Insert from SysUtils. For a real list with methods, Free Pascal ships TList, TStringList and the generic TFPGList / TList<T> — and those are objects, so the memory section applies to them.
Structure becomes record
A value type that copies on assignment, with methods — exactly what Structure is.
Option Strict On Imports System Public Structure Point Public X As Integer Public Y As Integer Public Sub New(x As Integer, y As Integer) Me.X = x Me.Y = y End Sub Public Function Describe() As String Return $"({X}, {Y})" End Function End Structure Module RecordDemo Sub Main() Dim origin As New Point(1, 2) Dim copy As Point = origin copy.X = 99 Console.WriteLine(origin.Describe()) Console.WriteLine(copy.Describe()) End Sub End Module
program RecordDemo; uses SysUtils; type TPoint = record X, Y: Integer; end; function Describe(const APoint: TPoint): string; begin Result := Format('(%d, %d)', [APoint.X, APoint.Y]); end; var Origin, Copy_: TPoint; begin Origin.X := 1; Origin.Y := 2; Copy_ := Origin; { copies, like a Structure } Copy_.X := 99; WriteLn(Describe(Origin)); WriteLn(Describe(Copy_)); end.
Structure ... End Structure becomes record ... end, and assignment copies the whole value in both. A plain record is data only, so the behaviour becomes a standalone routine — which is why the example passes the point in rather than calling a method on it. Records can carry methods, but only with {$modeswitch advancedrecords} turned on, and even then declaration and implementation stay separate. A record needs no constructor and no New: declaring the variable is enough, and it needs no freeing.
Control Flow
Select Case becomes case
A near-literal translation, including the range form.
Option Strict On Imports System Module CaseDemo Function Describe(code As Integer) As String Select Case code Case 1 Return "one" Case 2, 3 Return "two or three" Case 4 To 6 Return "four to six" Case Else Return "something else" End Select End Function Sub Main() Console.WriteLine(Describe(1)) Console.WriteLine(Describe(3)) Console.WriteLine(Describe(5)) Console.WriteLine(Describe(9)) End Sub End Module
program CaseDemo; function Describe(Code: Integer): string; begin case Code of 1: Result := 'one'; 2, 3: Result := 'two or three'; 4..6: Result := 'four to six'; else Result := 'something else'; end; end; begin WriteLn(Describe(1)); WriteLn(Describe(3)); WriteLn(Describe(5)); WriteLn(Describe(9)); end.
Select Case x becomes case x of, Case labels become value:, Case 2, 3 keeps its comma, and Case 4 To 6 becomes 4..6. Case Else becomes a bare else. No branch falls through, as in Visual Basic. The one limitation: a Pascal case works only on ordinal types — integers, characters, enumerations, booleans — so Select Case on a String has to become an if chain.
Loops, including repeat ... until
Every Visual Basic loop has a direct counterpart — including Loop Until, which almost nothing else on this anchor does.
Option Strict On Imports System Imports System.Collections.Generic Module LoopDemo Sub Main() For index As Integer = 1 To 5 Console.Write(index & " ") Next Console.WriteLine() For countdown As Integer = 5 To 1 Step -1 Console.Write(countdown & " ") Next Console.WriteLine() Dim words As New List(Of String) From {"alpha", "beta"} For Each word As String In words Console.WriteLine(word.ToUpper()) Next Dim attempt As Integer = 0 Do attempt += 1 Loop Until attempt >= 2 Console.WriteLine(attempt) End Sub End Module
program LoopDemo; uses SysUtils; var Index, Attempt: Integer; Word_: string; Words: array of string; begin for Index := 1 to 5 do Write(Index, ' '); WriteLn; for Index := 5 downto 1 do Write(Index, ' '); WriteLn; Words := ['alpha', 'beta']; for Word_ in Words do WriteLn(UpperCase(Word_)); Attempt := 0; repeat Attempt := Attempt + 1; until Attempt >= 2; WriteLn(Attempt); end.
For i = 1 To 5 becomes for Index := 1 to 5 do, inclusive at both ends, so no off-by-one to convert. Step -1 becomes downto; other step sizes have no form and need a while. For Each becomes for x in collection do. And Do ... Loop Until becomes repeat ... until, which needs no inversion and no begin/end — the repeat and until bracket the block themselves.
The With block is still here
Of every target on this anchor, Pascal is the only one that kept this construct.
Option Strict On Imports System Public Class Report Public Property Title As String = "" Public Property Pages As Integer Public Property Author As String = "" End Class Module WithDemo Sub Main() Dim report As New Report() With report .Title = "Quarterly" .Pages = 12 .Author = "Ada" End With Console.WriteLine($"{report.Title} / {report.Pages} / {report.Author}") End Sub End Module
program WithDemo; type TReport = record Title: string; Pages: Integer; Author: string; end; var Report: TReport; begin with Report do begin Title := 'Quarterly'; Pages := 12; Author := 'Ada'; end; WriteLn(Report.Title, ' / ', Report.Pages, ' / ', Report.Author); end.
With obj ... End With becomes with Obj do begin ... end, and it does the same job — though note the members are written without a leading dot. C#, Python, Ruby, Go, Rust and JavaScript all lack it entirely; only Pascal has it, which is unsurprising given that Visual Basic borrowed the idea from the same tradition. Modern Pascal style is wary of it for the same reason modern Visual Basic style is: a nested with makes it genuinely unclear which object a bare name belongs to.
Procedures & Functions
Sub and Function become procedure and function
The Sub/Function distinction survives intact — this is the language Visual Basic got it from.
Option Strict On Imports System Module RoutineDemo Sub Announce(message As String) Console.WriteLine($"** {message} **") End Sub Function Add(left As Integer, right As Integer) As Integer Return left + right End Function Sub Main() Announce("starting") Console.WriteLine(Add(2, 3)) End Sub End Module
program RoutineDemo; procedure Announce(const Message: string); begin WriteLn('** ', Message, ' **'); end; function Add(Left, Right: Integer): Integer; begin Result := Left + Right; end; begin Announce('starting'); WriteLn(Add(2, 3)); end.
Sub becomes procedure and Function ... As Integer becomes function ... : Integer, with the return type after a colon exactly where As Integer sits. Return value becomes Result := value, which will feel familiar to anyone who remembers VB6's Add = 5; there is also an Exit statement for an early return. Parameters sharing a type share the annotation, and const on a parameter promises not to modify it and lets the compiler avoid a copy.
ByRef becomes var
The feature survives with a shorter keyword, and Pascal splits it the way C# does.
Option Strict On Imports System Module ByRefDemo Sub Twice(ByRef value As Integer) value *= 2 End Sub Function TryHalve(input As Integer, ByRef result As Integer) As Boolean If input Mod 2 <> 0 Then Return False result = input \ 2 Return True End Function Sub Main() Dim number As Integer = 21 Twice(number) Console.WriteLine(number) Dim half As Integer If TryHalve(10, half) Then Console.WriteLine(half) End Sub End Module
program ByRefDemo; procedure Twice(var Value: Integer); begin Value := Value * 2; end; function TryHalve(Input: Integer; out Halved: Integer): Boolean; begin if Input mod 2 <> 0 then Exit(False); Halved := Input div 2; Result := True; end; var Number, Half: Integer; begin Number := 21; Twice(Number); WriteLn(Number); if TryHalve(10, Half) then WriteLn(Half); end.
ByRef becomes var when the value goes in and comes back, and out when it is purely an output — the same distinction C# draws. ByVal is the default and needs no keyword. Unlike C#, the call site writes nothing: Twice(Number) looks like an ordinary call, exactly as in Visual Basic, so you must read the declaration to know. Note div and mod, which the next section covers, and Exit(False) as an early return with a value.
Optional parameters and overloading
Both features exist, and one of them has to be asked for explicitly.
Option Strict On Imports System Module OverloadDemo Function Greet(name As String, Optional greeting As String = "Hello") As String Return $"{greeting}, {name}" End Function Function Area(side As Double) As Double Return side * side End Function Function Area(width As Double, height As Double) As Double Return width * height End Function Sub Main() Console.WriteLine(Greet("Ada")) Console.WriteLine(Greet("Grace", "Welcome")) Console.WriteLine(Area(3)) Console.WriteLine(Area(3, 4)) End Sub End Module
program OverloadDemo; uses SysUtils; function Greet(const Name: string; const Greeting: string = 'Hello'): string; begin Result := Greeting + ', ' + Name; end; function Area(Side: Double): Double; overload; begin Result := Side * Side; end; function Area(Width, Height: Double): Double; overload; begin Result := Width * Height; end; begin WriteLn(Greet('Ada')); WriteLn(Greet('Grace', 'Welcome')); WriteLn(Area(3):0:2); WriteLn(Area(3, 4):0:2); end.
A default value makes a parameter optional, and the Optional keyword disappears — the same as everywhere else on this anchor. Overloading works too, but each overload must be marked overload, otherwise the second declaration is an error rather than an overload. Named arguments do not exist: greeting:= has no counterpart, so a middle argument cannot be skipped.
Classes & Objects
A class, split into declaration and implementation
Everything you expect is here — and so is a try...finally around a single object, which the next section explains.
Option Strict On Imports System Public Class Person Private ReadOnly _name As String Private ReadOnly _age As Integer Public Sub New(name As String, age As Integer) _name = name _age = age End Sub Public Function Describe() As String Return $"{_name}, age {_age}" End Function End Class Module ClassDemo Sub Main() Dim person As New Person("Ada", 36) Console.WriteLine(person.Describe()) End Sub End Module
program ClassDemo; uses SysUtils; type TPerson = class private FName: string; FAge: Integer; public constructor Create(const AName: string; AAge: Integer); function Describe: string; end; constructor TPerson.Create(const AName: string; AAge: Integer); begin FName := AName; FAge := AAge; end; function TPerson.Describe: string; begin Result := Format('%s, age %d', [FName, FAge]); end; var Person: TPerson; begin Person := TPerson.Create('Ada', 36); try WriteLn(Person.Describe); finally Person.Free; end; end.
A class declares its members in the type block and implements them afterwards, which is more typing than Visual Basic's single block and makes the public shape of a class readable in one place. Public Sub New becomes constructor Create, called as TPerson.Create(...) — on the type, not with a New keyword. Me becomes Self. Visibility sections (private, protected, public, published) group members rather than marking each one.
Properties
Real properties, from the same tradition — and this is where Visual Basic and Delphi visibly share ancestry.
Option Strict On Imports System Public Class Temperature Private _celsius As Double Public Property Celsius As Double Get Return _celsius End Get Set(value As Double) _celsius = Math.Max(value, -273.15) End Set End Property Public ReadOnly Property Fahrenheit As Double Get Return _celsius * 9.0 / 5.0 + 32 End Get End Property End Class Module PropertyDemo Sub Main() Dim reading As New Temperature() reading.Celsius = 100.0 Console.WriteLine(reading.Fahrenheit) reading.Celsius = -500 Console.WriteLine(reading.Celsius) End Sub End Module
program PropertyDemo; uses Math; type TTemperature = class private FCelsius: Double; procedure SetCelsius(AValue: Double); function GetFahrenheit: Double; public property Celsius: Double read FCelsius write SetCelsius; property Fahrenheit: Double read GetFahrenheit; end; procedure TTemperature.SetCelsius(AValue: Double); begin FCelsius := Max(AValue, -273.15); end; function TTemperature.GetFahrenheit: Double; begin Result := FCelsius * 9.0 / 5.0 + 32; end; var Reading: TTemperature; begin Reading := TTemperature.Create; try Reading.Celsius := 100.0; WriteLn(Reading.Fahrenheit:0:2); Reading.Celsius := -500; WriteLn(Reading.Celsius:0:2); finally Reading.Free; end; end.
A property declares what to read and what to write, and either may be a field directly or a method. So read FCelsius write SetCelsius is a plain read with a validated write, and a property with only read is ReadOnly Property. Callers write Reading.Celsius := 100, identical to Visual Basic. There is a fourth visibility, published, which makes a property visible to the Lazarus form designer — the mechanism the object inspector is built on.
Inheritance and interfaces
Every keyword has a counterpart, and they are almost the same words.
Option Strict On Imports System Public MustInherit Class Shape Public MustOverride Function Area() As Double Public Overridable Function Describe() As String Return $"area {Area():F2}" End Function End Class Public Class Circle Inherits Shape Private ReadOnly _radius As Double Public Sub New(radius As Double) _radius = radius End Sub Public Overrides Function Area() As Double Return Math.PI * _radius * _radius End Function Public Overrides Function Describe() As String Return "circle: " & MyBase.Describe() End Function End Class Module InheritanceDemo Sub Main() Dim shape As Shape = New Circle(2.0) Console.WriteLine(shape.Describe()) End Sub End Module
program InheritanceDemo; uses SysUtils, Math; type TShape = class public function Area: Double; virtual; abstract; function Describe: string; virtual; end; TCircle = class(TShape) private FRadius: Double; public constructor Create(ARadius: Double); function Area: Double; override; function Describe: string; override; end; function TShape.Describe: string; begin Result := Format('area %.2f', [Area]); end; constructor TCircle.Create(ARadius: Double); begin FRadius := ARadius; end; function TCircle.Area: Double; begin Result := Pi * FRadius * FRadius; end; function TCircle.Describe: string; begin Result := 'circle: ' + inherited Describe; end; var Shape: TShape; begin Shape := TCircle.Create(2.0); try WriteLn(Shape.Describe); finally Shape.Free; end; end.
Inherits Shape becomes class(TShape), Overridable becomes virtual, Overrides becomes override, MustOverride becomes virtual; abstract, and MyBase becomes inherited. As in .NET a method is not overridable unless marked, and a class extends one class. Interfaces exist too — IGreeter = interface and class(TObject, IGreeter) — and are reference-counted, which interacts with the memory rules in the next section.
Memory: There Is No Collector
Every object you create, you free
This is the single biggest change in the whole move, and it is not a syntax difference.
Option Strict On Imports System Imports System.Text Module MemoryDemo Sub Main() Dim builder As New StringBuilder() builder.Append("first") builder.Append(" second") Console.WriteLine(builder.ToString()) ' The garbage collector reclaims it, eventually, unprompted End Sub End Module
program MemoryDemo; uses Classes; var Lines: TStringList; begin Lines := TStringList.Create; try Lines.Add('first'); Lines.Add('second'); WriteLn(Lines.Text); finally Lines.Free; { REQUIRED — nothing does this for you } end; end.
There is no garbage collector. Every object created with Create must be released with Free, and forgetting is a memory leak rather than an error. The universal idiom is the one above: Create, then try ... finally Free, so the object is released even if the block raises. Records, strings, dynamic arrays and interfaces are all managed automatically — it is only class instances you own. A Visual Basic programmer who has never thought about lifetime has to start, and it is the main thing to budget learning time for.
Letting an owner do the freeing
A collection holds references, not ownership — and that distinction is what a .NET programmer has never had to think about.
Option Strict On Imports System Imports System.Collections.Generic Public Class Item Public Property Name As String = "" End Class Module OwnerDemo Sub Main() Dim items As New List(Of Item) From { New Item With {.Name = "first"}, New Item With {.Name = "second"} } For Each item As Item In items Console.WriteLine(item.Name) Next ' Nothing to release — the collector handles all of it End Sub End Module
program OwnerDemo; uses Classes, SysUtils; type TItem = class public Name: string; constructor Create(const AName: string); end; constructor TItem.Create(const AName: string); begin Name := AName; end; var Items: TList; Index: Integer; begin Items := TList.Create; try Items.Add(TItem.Create('first')); Items.Add(TItem.Create('second')); for Index := 0 to Items.Count - 1 do WriteLn(TItem(Items[Index]).Name); finally for Index := 0 to Items.Count - 1 do TItem(Items[Index]).Free; { the list does NOT free its contents } Items.Free; end; end.
Freeing the list does not free what is in it. Either release each item yourself, as above, or use a container that owns its contents: TObjectList.Create(True) takes an OwnsObjects flag and frees each item when it is freed, and the Lazarus component model does the same — a control added to a form is freed with the form. The discipline is to decide, for every object, who owns it, and to write that down. It is the habit that makes the absence of a collector manageable rather than frightening.
Error Handling
Try/Catch becomes try/except
Almost a direct translation, with one structural wrinkle: finally needs its own block.
Option Strict On Imports System Module TryDemo Sub Main() Try Dim value As Integer = Integer.Parse("not a number") Console.WriteLine(value) Catch error_ As FormatException Console.WriteLine($"Bad format: {error_.Message}") Catch error_ As Exception Console.WriteLine($"Something else: {error_.Message}") Finally Console.WriteLine("always runs") End Try End Sub End Module
program TryDemo; uses SysUtils; begin try try WriteLn(StrToInt('not a number')); except on E: EConvertError do WriteLn('Bad format: ', E.Message); on E: Exception do WriteLn('Something else: ', E.Message); end; finally WriteLn('always runs'); end; end.
Trytry, Catch e As Ton E: T do, End Tryend. Order still matters, most specific first, and E.Message reads the same. The wrinkle: Pascal has no try...except...finally in one block, so a routine needing both nests a try..except inside a try..finally, as above. That is why the memory section's try..finally Free is always its own block.
Raising your own exception
Defining an exception is the inheritance you already saw, applied to one base class — and note who frees it.
Option Strict On Imports System Public Class InsufficientFundsException Inherits Exception Public ReadOnly Property Shortfall As Decimal Public Sub New(shortfall As Decimal) MyBase.New($"Short by {shortfall}") Me.Shortfall = shortfall End Sub End Class Module RaiseDemo Sub Withdraw(balance As Decimal, amount As Decimal) If amount > balance Then Throw New InsufficientFundsException(amount - balance) End If End Sub Sub Main() Try Withdraw(50D, 75D) Catch error_ As InsufficientFundsException Console.WriteLine($"{error_.Message} (short {error_.Shortfall})") End Try End Sub End Module
program RaiseDemo; uses SysUtils; type EInsufficientFunds = class(Exception) public Shortfall: Currency; constructor Create(AShortfall: Currency); end; constructor EInsufficientFunds.Create(AShortfall: Currency); begin inherited CreateFmt('Short by %.2f', [AShortfall]); Shortfall := AShortfall; end; procedure Withdraw(Balance, Amount: Currency); begin if Amount > Balance then raise EInsufficientFunds.Create(Amount - Balance); end; begin try Withdraw(50, 75); except on E: EInsufficientFunds do WriteLn(E.Message, ' (short ', E.Shortfall:0:2, ')'); end; end.
Throw New becomes raise ...Create(...), and Inherits Exception becomes class(Exception) from SysUtils. MyBase.New becomes inherited Create, or CreateFmt for a formatted message. Convention prefixes exception class names with E rather than suffixing Exception. The one thing to know given the previous section: a raised exception object is freed automatically when the handler finishes, so this is the one Create you must not Free.
Units, Lazarus & Deployment
Modules become units
A unit is a file with a public half and a private half, declared separately.
Option Strict On Imports System Imports System.Math Namespace Geometry Public Module Area Public Function Rectangle(width As Double, height As Double) As Double Return width * height End Function End Module End Namespace Module UnitDemo Sub Main() Console.WriteLine(Geometry.Area.Rectangle(3, 4)) End Sub End Module
program UnitDemo; uses SysUtils, Math; { In a real project this would be its own file, geometry.pas: unit Geometry; interface function RectangleArea(Width, Height: Double): Double; implementation function RectangleArea(Width, Height: Double): Double; begin Result := Width * Height; end; end. } function RectangleArea(Width, Height: Double): Double; begin Result := Width * Height; end; begin WriteLn(RectangleArea(3, 4):0:2); WriteLn(Max(3, 7)); end.
Imports becomes uses, and names come into scope unqualifiedMax, not Math.Max — with later units in the list winning a name clash. A unit has an interface section listing what the outside world may use and an implementation section holding the bodies plus anything private. That split is more explicit than Public/Private on each member, and it gives you a readable summary of a unit's API at the top of its own file.
Lazarus is the reason you are reading this page
The part no other target on this anchor can offer: the workflow survives.
Option Strict On Imports System Imports System.Collections.Generic Module LazarusDemo Sub Main() ' In WinForms the designer generates the form class and ' controls are fields on it: ' Label1.Caption = "Hello" ' Button1.OnClick = AddressOf Button1_Click Dim story As New Dictionary(Of String, String) From { {"designer", "WinForms / WPF"}, {"packages", "NuGet"}, {"output", "exe plus a runtime"} } For Each entry In story Console.WriteLine($"{entry.Key}: {entry.Value}") Next End Sub End Module
program LazarusDemo; { In Lazarus the designer generates the same shape: procedure TForm1.Button1Click(Sender: TObject); begin Label1.Caption := 'Hello'; end; } type TPair = record Key, Value: string; end; var Story: array of TPair; Index: Integer; begin SetLength(Story, 3); Story[0].Key := 'designer'; Story[0].Value := 'Lazarus LCL'; Story[1].Key := 'packages'; Story[1].Value := 'Online Package Manager'; Story[2].Key := 'output'; Story[2].Value := 'one native binary, no runtime'; for Index := Low(Story) to High(Story) do WriteLn(Story[Index].Key, ': ', Story[Index].Value); end.
Lazarus is a free, open-source IDE with a drag-and-drop form designer, an object inspector, and double-click-to-write-an-event-handler — the VB6 and WinForms workflow, on a compiler nobody owns. The LCL is the control library; Label1.Caption := 'Hello' is Label1.Text = "Hello". It cross-compiles to Windows, macOS, Linux and Raspberry Pi from the same source, and the output is one native binary with no runtime to install. Packages come from the Online Package Manager rather than NuGet.
⚠ Gotchas for Visual Basic Programmers
⚠ / always produces a Real
The one page on this anchor where the division rule needs no warning at all.
Option Strict On Imports System Module DivisionGotcha Sub Main() Dim quotient As Integer = 17 \ 5 Dim exact As Double = 17 / 5 Dim remainder As Integer = 17 Mod 5 Console.WriteLine($"{quotient} {exact} {remainder}") End Sub End Module
program DivisionGotcha; var Quotient, Remainder: Integer; Exact: Double; begin Quotient := 17 div 5; Exact := 17 / 5; Remainder := 17 mod 5; WriteLn(Quotient, ' ', Exact:0:1, ' ', Remainder); end.
Pascal makes exactly the distinction Visual Basic makes: / always produces a Real, and div is integer division. So \ becomes div, / stays /, and Mod becomes mod — no truncation trap, no silently changed meaning. Assigning the result of / to an Integer is a compile error rather than a rounding, which is Option Strict On behaviour by default. Every other target on this anchor except Python gets this wrong for a Visual Basic reader; Pascal does not.
⚠ The semicolon is a separator
The rule that produces the most baffling early compiler errors, and it takes one sentence to state.
Option Strict On Imports System Module SemicolonGotcha Sub Main() Dim value As Integer = 5 If value > 3 Then Console.WriteLine("big") Else Console.WriteLine("small") End If End Sub End Module
program SemicolonGotcha; var Value: Integer; begin Value := 5; if Value > 3 then WriteLn('big') { NO semicolon here } else WriteLn('small'); if Value > 3 then begin WriteLn('also big'); end { and none here either } else WriteLn('small'); end.
A semicolon separates statements rather than terminating them, so there is never one immediately before else — the if...then...else is a single statement and a semicolon would end it early. The same applies before until and before the final end, though a semicolon there is harmless because it separates from an empty statement. Free Pascal's error for this is Fatal: Syntax error, ";" expected but "ELSE" found, which is the compiler being about as helpful as it can be.
⚠ Forgetting Free is a leak, not an error
The consequence of no garbage collector, seen in the shape a loop has to take.
Option Strict On Imports System Imports System.Collections.Generic Module LeakGotcha Sub Main() For index As Integer = 1 To 3 Dim items As New List(Of Integer) From {index} Console.WriteLine(items.Count) Next ' Three lists allocated, all reclaimed automatically End Sub End Module
program LeakGotcha; uses Classes; var Index: Integer; Items: TStringList; begin for Index := 1 to 3 do begin Items := TStringList.Create; try Items.Add('value'); WriteLn(Items.Count); finally Items.Free; { omit this and the loop leaks three objects } end; end; end.
Leaving out the Free compiles cleanly, runs correctly, and leaks — three objects here, thousands in a long-running service. Nothing warns you at build time. Two things make this tractable: the try ... finally Free idiom applied without exception, and Free Pascal's -gh heaptrc option, which prints every unfreed block with its allocation stack when the program exits. Turn it on in debug builds from day one; it is the closest thing to a collector you get.
⚠ Strings are 1-based, dynamic arrays are 0-based
Two indexing conventions in one language, and the compiler will not tell you which one you meant.
Option Strict On Imports System Module IndexGotcha Sub Main() Dim text As String = "Visual" Dim values() As Integer = {10, 20, 30} ' Both zero-based Console.WriteLine(text(0)) Console.WriteLine(values(0)) End Sub End Module
program IndexGotcha; var Text: string; Values: array of Integer; Index: Integer; begin Text := 'Visual'; Values := [10, 20, 30]; WriteLn(Text[1]); { strings start at 1 } WriteLn(Values[0]); { dynamic arrays start at 0 } { Low/High work on ARRAYS. On a string they report the ShortString capacity range, NOT the live indices: } WriteLn(Low(Values), ' ', High(Values)); WriteLn(Low(Text), ' ', High(Text)); for Index := 1 to Length(Text) do Write(Text[Index]); WriteLn; end.
A Pascal string is indexed from 1, a dynamic array from 0, and a fixed array from whatever bounds you declared. That inconsistency is the source of most off-by-one bugs in Pascal code, and there is a second trap on top of it: Low and High are reliable on arrays and misleading on strings — the output above shows them reporting 0 and 255, the ShortString capacity range, rather than 1 and the length. So use Low/High for arrays and 1 to Length(Text) for strings. (In Delphi's mobile compilers strings became 0-based, which caused enough trouble that Free Pascal did not follow.)
⚠ No My namespace, and no .NET at all
The conveniences have counterparts — what is gone is the entire .NET base class library.
Option Strict On Imports System Module PlatformGotcha Sub Main() Console.WriteLine(Environment.MachineName.Length > 0) Console.WriteLine(IsNumeric("42")) Console.WriteLine(Now.Year > 2000) End Sub End Module
program PlatformGotcha; uses SysUtils, DateUtils; var Parsed: Integer; begin WriteLn(Length(GetEnvironmentVariable('HOME')) >= 0); WriteLn(TryStrToInt('42', Parsed)); WriteLn(YearOf(Now) > 2000); end.
IsNumeric becomes TryStrToInt, Now is Now (the same name, from SysUtils), My.Computer.FileSystem becomes SysUtils and FileUtil. The larger point: there is no .NET. No System.Text.Json, no HttpClient, no LINQ, no async/await, no NuGet. Free Pascal's RTL and the Lazarus component library are broad and mature, but they are a different library with different names, and any code that leans on a .NET-specific type has to be rewritten rather than translated.