PONY λ M2 Modula-2

Swift.CodeCompared.To/Python

An interactive executable cheatsheet comparing Swift and Python

Swift 6.3 Python 3.13
Basics & Syntax
Hello, World
print("Hello, World!")
print("Hello, World!")
Identical — and it is the last thing on this page that is. Both languages allow statements at file scope, so neither needs a main. Python has no semicolons, no braces, and no type annotations required anywhere; indentation is not a style choice but the actual block structure, and getting it wrong is a syntax error.
No let, no var, no types
let fixed = 1 var counter = 0 counter += 1 // fixed = 2 ← will not compile let explicit: Int64 = 42 let name = "Ada" print("\(name): \(fixed + counter), \(explicit)") // A variable's type is fixed forever. // name = 42 ← will not compile: cannot assign Int to String
# No keyword at all. A name springs into existence on assignment. counter = 0 counter += 1 # SCREAMING_CASE is a CONVENTION for a constant. Nothing enforces it: you can # reassign it, and nobody will stop you. There is no 'let'. FIXED = 1 name = "Ada" print(f"{name}: {FIXED + counter}, {42}") # an f-string is the interpolation # And a name can hold anything, at any time. name = 42 print(name, type(name))
The immutability discipline you have built in Swift has nowhere to live: there is no let, and a SCREAMING_CASE constant is a convention that nothing enforces. A name is a label that can be reattached to any object of any type at any moment, so name = 42 after name = "Ada" is perfectly legal. F-strings (f"{name}") are the interpolation, and they are excellent — they take any expression, and f"{value=}" even prints the expression alongside its value, which is the fastest debugging tool in the language.
Dynamic Typing & Type Hints
Type hints look like Swift and check nothing
This is the concept that most misleads a Swift developer. Python's annotations use nearly the same syntax you already know — and the interpreter ignores them completely.
// The compiler enforces every one of these, before the program runs. func double(_ number: Int) -> Int { number * 2 } print(double(21)) // print(double("nope")) ← compile error, caught at build time struct User { let name: String let age: Int } let user = User(name: "Ada", age: 36) print(user.name) // print(user.nmae) ← compile error: no such property
# The annotations are real syntax and completely optional. At RUN TIME they are # stored as metadata and otherwise ignored — nothing checks them. def double(number: int) -> int: return number * 2 print(double(21)) print(double("nope")) # "nopenope" — the annotation lied, and Python did not care # What DOES check them is a separate tool you run yourself: mypy, pyright, or # your editor. In a codebase without one, the hints are documentation. class User: def __init__(self, name: str, age: int): self.name = name self.age = age user = User("Ada", 36) print(user.name) # A misspelled attribute is not an error until that LINE RUNS. try: print(user.nmae) except AttributeError as error: print("AttributeError:", error)
Read the second print in the right column again: double("nope") returns "nopenope", because * on a string repeats it and the int annotation is not enforced by anything. Type hints are checked by a separate toolmypy or pyright, run in CI or by your editor — and a project that does not run one gets no checking at all. The practical advice for a Swift developer: annotate everything, turn on a strict type checker from day one, and treat it as the compiler you no longer have. It is genuinely good (gradual typing has come a long way), but it is opt-in, and it is not the language.
None Instead of Optionals
None is just a value
func findName(_ id: Int) -> String? { id == 1 ? "Ada" : nil } // The compiler REFUSES to let you use it without unwrapping. print(findName(1)?.uppercased() ?? "(none)") if let name = findName(1) { print(name.count) } func shout(_ id: Int) -> String { guard let name = findName(id) else { return "(nobody)" } return name.uppercased() } print(shout(99))
def find_name(id: int) -> str | None: # the hint says it may be None... return "Ada" if id == 1 else None # ...and nothing enforces it # There is no ?. and no ??. You check, by hand, or you crash. name = find_name(99) print(name.upper() if name is not None else "(none)") # The walrus operator is the closest thing to 'if let'. if (found := find_name(1)) is not None: print(len(found)) def shout(id: int) -> str: name = find_name(id) if name is None: # the guard-let equivalent: an early return return "(nobody)" return name.upper() print(shout(99)) # Forget the check and you get the Python equivalent of a nil crash: try: print(find_name(99).upper()) except AttributeError as error: print("AttributeError:", error) # 'NoneType' object has no attribute 'upper'
Absence is a value (None) and nothing makes you check for it, so the safety Swift's optionals give you must be recovered with a type checker (str | None is enforced by mypy, and this is the strongest argument for running one). The idioms: x is None rather than x == None, an early return instead of guard let, and the walrus operator (:=) when you want to test and bind in one line. And the error you will meet on your first day is AttributeError: 'NoneType' object has no attribute '…' — that is Python's nil-crash, arriving at run time, in production, exactly where Swift would have refused to compile.
Everything Is a Reference
There are no value types
The Swift habit that will bite you first. Assignment never copies — everything is a reference to a mutable object.
// A struct is a VALUE: assignment copies. var first = [1, 2, 3] var second = first second.append(4) print(first) // [1, 2, 3] — untouched print(second) // [1, 2, 3, 4] struct Point { var x: Int } var pointA = Point(x: 1) var pointB = pointA pointB.x = 99 print(pointA.x, pointB.x) // 1 99 // And a function cannot mutate what you pass it, unless you say inout. func addOne(_ items: [Int]) -> [Int] { items + [1] } print(addOne(first), first)
# Assignment binds a NAME to the same object. No copy, ever. first = [1, 2, 3] second = first second.append(4) print(first) # [1, 2, 3, 4] — mutated through the other name! print(second) # To copy, say so. import copy third = first.copy() # shallow deep = copy.deepcopy(first) # and deepcopy for nested structures third.append(99) print(first, third) # The classic beginner trap, and it catches experts too: a mutable DEFAULT # argument is created ONCE, at definition time, and shared by every call. def broken(item, items=[]): items.append(item) return items print(broken(1)) # [1] print(broken(2)) # [1, 2] ← the same list, still there # The fix, and you will write it a thousand times: def correct(item, items=None): if items is None: items = [] items.append(item) return items print(correct(1), correct(2))
Python has no value types at all, so every assignment aliases and every function receives a reference to your object — mutate it inside and the caller sees it. The defenses are .copy() (shallow) and copy.deepcopy() (recursive), and you must remember to reach for them. Then read the broken function again, because it is the single most famous trap in the language: a mutable default argument is evaluated once when the function is defined, so that list persists between calls and accumulates. Swift's value semantics make both of these unwritable; the Python idiom is items=None plus a check, and it is not optional.
Functions
Functions, defaults, and *args
func greet(name: String, salutation: String = "Hello") -> String { "\(salutation), \(name)!" } func total(_ numbers: Int...) -> Int { numbers.reduce(0, +) } print(greet(name: "Ada")) print(greet(name: "Ada", salutation: "Hi")) print(total(1, 2, 3)) // Closures, with the trailing-closure syntax. let numbers = [1, 2, 3] print(numbers.map { $0 * 2 }) let add: (Int, Int) -> Int = { $0 + $1 } print(add(2, 3))
def greet(name: str, salutation: str = "Hello") -> str: return f"{salutation}, {name}!" # *args collects positional arguments; **kwargs collects named ones. def total(*numbers: int) -> int: return sum(numbers) def configure(**options): return options print(greet("Ada")) print(greet("Ada", salutation="Hi")) # every parameter can be named — no labels print(total(1, 2, 3)) print(total(*[4, 5, 6])) # * spreads a list into the arguments print(configure(host="example.com", port=8080)) # Lambdas exist but are limited to ONE expression — no statements, no blocks. # The idiomatic replacement is a comprehension or a named function. numbers = [1, 2, 3] print([number * 2 for number in numbers]) # not numbers.map { } print(list(map(lambda number: number * 2, numbers))) add = lambda first, second: first + second print(add(2, 3))
Named arguments and defaults are here and are better than Swift's in one respect: any parameter can be passed by name, with no argument label to declare. *args is the variadic (and *list at a call site spreads a list into it, which is Swift's missing spread operator), while **kwargs collects arbitrary named arguments — a thing Swift cannot express at all. The real adjustment is that lambda is one expression only: there are no multi-statement closures, so the trailing-closure style you write constantly in Swift becomes either a comprehension (the idiomatic choice) or a named function defined above.
Collections & Comprehensions
map and filter become a comprehension
let names = ["Ada", "Grace", "Alan", "Barbara"] print(names.filter { $0.count > 3 }.map { $0.uppercased() }) print(names.map(\.count).reduce(0, +)) print(names.sorted()) print(names.first { $0.hasPrefix("G") } ?? "(none)") var ages = ["Ada": 36, "Alan": 41] ages["Grace"] = 45 print(ages["Ada"] ?? 0) // an Optional print(ages["Nobody"] ?? 0) let doubledAges = ages.mapValues { $0 * 2 } print(doubledAges.count)
names = ["Ada", "Grace", "Alan", "Barbara"] # The comprehension is THE idiom: filter and map in one, and it reads left to right. print([name.upper() for name in names if len(name) > 3]) print(sum(len(name) for name in names)) # a GENERATOR: lazy, no list built print(sorted(names)) print(next((name for name in names if name.startswith("G")), "(none)")) ages = {"Ada": 36, "Alan": 41} ages["Grace"] = 45 print(ages["Ada"]) print(ages.get("Nobody", 0)) # [] on a missing key RAISES KeyError; .get does not # Dict and set comprehensions, too. doubled = {name: age * 2 for name, age in ages.items()} print(len(doubled)) print({len(name) for name in names}) # a set
The comprehension replaces map, filter, and often reduce, and it works for lists, dicts, and sets with the same syntax. Written with parentheses instead of brackets it becomes a generator — lazy, one element at a time, no intermediate list — which is Swift's .lazy. One difference with teeth: subscripting a dict with a missing key raises KeyError rather than returning an optional, so .get(key, default) is what you actually want (and collections.defaultdict when you want inserts too).
Classes & Dataclasses
struct becomes @dataclass
struct Person: Equatable { var name: String var age: Int = 0 var isAdult: Bool { age >= 18 } // a computed property func greeting() -> String { "Hi, \(name)" } } let ada = Person(name: "Ada", age: 36) print(ada) print(ada.isAdult, ada.greeting()) print(ada == Person(name: "Ada", age: 36)) // structural equality, free // A copy, with one field changed. var older = ada older.age = 37 print(older)
from dataclasses import dataclass, replace, field @dataclass class Person: name: str age: int = 0 # A computed property: a method with @property, called without parentheses. @property def is_adult(self) -> bool: return self.age >= 18 def greeting(self) -> str: return f"Hi, {self.name}" ada = Person(name="Ada", age=36) print(ada) # __repr__, generated print(ada.is_adult, ada.greeting()) print(ada == Person("Ada", 36)) # __eq__, generated # replace() is Swift's copy-with-changes. Plain assignment would ALIAS. older = replace(ada, age=37) print(older, ada) # @dataclass(frozen=True) makes it immutable and hashable — as close to a Swift # struct as Python gets. Reach for it by default. @dataclass(frozen=True) class Point: x: int y: int point = Point(1, 2) print({point}) # hashable, so it works in a set # point.x = 9 ← raises FrozenInstanceError
@dataclass generates __init__, __repr__ and __eq__ from the annotated fields, which covers most of what a Swift struct gives you — but it is still a class, so assignment aliases and replace(instance, field=value) is how you produce a modified copy. The closest thing to real value semantics is @dataclass(frozen=True): immutable, hashable, and safe to share, and it is worth making your default. Note also @property, which turns a method into a computed property accessed without parentheses — the direct translation of Swift's var isAdult: Bool { … }.
Classes, self, and no access control
class Counter { private var count = 0 // genuinely private static let maximum = 100 func increment() { count += 1 } var value: Int { count } deinit { print("gone") } // deterministic, via ARC } let counter = Counter() counter.increment() counter.increment() print(counter.value, Counter.maximum) // counter.count ← will not compile: private
class Counter: maximum = 100 # a class attribute: Swift's 'static' def __init__(self): # A leading underscore means "private" BY CONVENTION. Nothing enforces it: # counter._count is perfectly legal, and linters will only tut at you. self._count = 0 def increment(self) -> None: # 'self' is EXPLICIT in every method signature self._count += 1 @property def value(self) -> int: return self._count counter = Counter() counter.increment() counter.increment() print(counter.value, Counter.maximum) print(counter._count) # reachable. Python trusts you. # And you can add an attribute that was never declared, at any time. counter.surprise = "hello" print(counter.surprise)
Three things to absorb. self is an explicit first parameter of every method — Python does not hide it. There is no access control: a leading underscore is a convention that says "do not touch", and nothing at all prevents you from touching it (a double underscore triggers name mangling, which is obfuscation rather than privacy). And objects are open — you can attach a brand-new attribute to an instance at run time, which is powerful, occasionally useful, and the reason a typo becomes a silently created field rather than an error.
Duck Typing & Protocols
Protocols become duck typing
protocol Describable { func describe() -> String } // Conformance is DECLARED, and the compiler checks it. struct Dog: Describable { func describe() -> String { "a dog" } } struct Robot: Describable { func describe() -> String { "BEEP" } } func announce(_ value: any Describable) { print(value.describe()) } announce(Dog()) announce(Robot()) // A type that does not conform CANNOT be passed. Checked at compile time.
# No protocol, no conformance, no declaration. If it has the method, it works. class Dog: def describe(self) -> str: return "a dog" class Robot: def describe(self) -> str: return "BEEP" def announce(value) -> None: print(value.describe()) # nothing checked this until it ran announce(Dog()) announce(Robot()) # Pass something without the method and it fails HERE, at run time. try: announce(42) except AttributeError as error: print("AttributeError:", error) # typing.Protocol brings STRUCTURAL typing back for the type checker (mypy) — # still no conformance declaration, but now a static tool verifies the shape. from typing import Protocol class Describable(Protocol): def describe(self) -> str: ... def announce_checked(value: Describable) -> None: print(value.describe()) announce_checked(Dog()) # mypy verifies Dog has describe(); Python does not
Duck typing is the whole story: a function accepts anything with the right method, no conformance is declared, and the check happens when the call runs. That buys enormous flexibility (any object can stand in for any other, which is why mocking in Python is trivial) and costs you the compiler — announce(42) is a perfectly good program until the moment it is not. typing.Protocol is the bridge: it describes the shape you need, and mypy verifies it statically, giving you back something close to a Swift protocol with structural rather than nominal conformance. Python still does not check it at run time.
Operator overloading is a dunder method
struct Money: CustomStringConvertible, Equatable, Comparable { let cents: Int var description: String { "$\(Double(cents) / 100)" } static func + (left: Money, right: Money) -> Money { Money(cents: left.cents + right.cents) } static func < (left: Money, right: Money) -> Bool { left.cents < right.cents } } let total = Money(cents: 500) + Money(cents: 250) print(total) print(Money(cents: 100) < total) print([Money(cents: 300), Money(cents: 100)].sorted().map(\.description))
from dataclasses import dataclass from functools import total_ordering @total_ordering # generates <=, >, >= from __eq__ and __lt__ @dataclass(frozen=True) class Money: cents: int def __str__(self) -> str: # CustomStringConvertible return f"${self.cents / 100}" def __add__(self, other: "Money") -> "Money": # the + operator return Money(self.cents + other.cents) def __lt__(self, other: "Money") -> bool: # the < operator return self.cents < other.cents total = Money(500) + Money(250) print(total) print(Money(100) < total) print([str(money) for money in sorted([Money(300), Money(100)])]) # The protocol you implement is the DUNDER method: __len__, __iter__, __eq__, # __getitem__, __contains__ — the whole language is built on them, and # implementing them makes your class work with len(), for, in, and [].
Every operator and every built-in function is a "dunder" (double-underscore) method: + calls __add__, len(x) calls x.__len__(), for x in thing calls __iter__, thing[0] calls __getitem__. That is Python's answer to Swift's standard-library protocols (Equatable, Comparable, Collection), and it is the whole language in one idea — implement the right dunder and your type works with the built-in syntax. Nothing declares that you conform to anything; you just define the method.
Enums & Pattern Matching
switch becomes match — without exhaustiveness
Python 3.10 added structural pattern matching, and it is closer to Swift's switch than you would expect — with one guarantee missing.
enum Shape { case circle(radius: Double) case rectangle(width: Double, height: Double) } func area(_ shape: Shape) -> Double { switch shape { // EXHAUSTIVE: a missing case will not compile case .circle(let radius): return 3.14159 * radius * radius case .rectangle(let width, let height): return width * height } } print(area(.circle(radius: 1))) print(area(.rectangle(width: 2, height: 3)))
from dataclasses import dataclass @dataclass class Circle: radius: float @dataclass class Rectangle: width: float height: float def area(shape) -> float: match shape: case Circle(radius=radius): # destructures as it matches return 3.14159 * radius * radius case Rectangle(width=width, height=height): return width * height case _: # NOT optional: nothing checks return 0.0 # that you covered every case print(area(Circle(1))) print(area(Rectangle(2, 3))) # match also destructures dicts and lists, which Swift cannot do: message = {"type": "click", "x": 3, "y": 4} match message: case {"type": "click", "x": x, "y": y}: print(f"click at {x},{y}") case {"type": "key", "key": key}: print(f"key {key}") match [1, 2, 3, 4]: case [first, *rest]: print(first, rest)
match is genuinely good: it destructures classes as it matches (case Circle(radius=radius)), it takes guards (if), and it goes further than Swift by matching the shape of dicts and lists — which is exactly what you want when handling decoded JSON. What it does not have is exhaustiveness: nothing tells you a case is missing, so case _ is doing real work, and adding a third shape silently falls into it. That guarantee — the one that makes Swift enums so safe to extend — has no Python equivalent, though a strict mypy setup can approximate it with assert_never.
Simple enums
enum Status: String, CaseIterable { case active = "running" case paused = "on hold" var isRunning: Bool { self == .active } } for status in Status.allCases { print(status, status.rawValue, status.isRunning) } print(Status(rawValue: "running") ?? .paused)
from enum import Enum class Status(Enum): ACTIVE = "running" # the value IS the rawValue PAUSED = "on hold" @property def is_running(self) -> bool: return self is Status.ACTIVE for status in Status: # iterable: allCases, free print(status.name, status.value, status.is_running) print(Status("running")) # construct from the value — raises ValueError on a miss print(Status.ACTIVE is Status("running")) # enum members are singletons: use 'is'
Python enums carry values (the rawValue), are iterable without asking (allCases for free), and can hold properties and methods. Two notes: constructing from a value raises ValueError on a miss rather than returning an optional, and members are singletons, so is is the idiomatic comparison. What Python's Enum cannot do is carry per-case associated values — for that you are back to the dataclasses-plus-match pattern in the previous concept.
Errors & Exceptions
throws disappears; exceptions are everywhere
enum ParseError: Error { case notANumber(String) case notPositive(Int) } // 'throws' is in the signature; 'try' is required at every call site. func parsePositive(_ text: String) throws -> Int { guard let number = Int(text) else { throw ParseError.notANumber(text) } guard number > 0 else { throw ParseError.notPositive(number) } return number } do { print(try parsePositive("42")) print(try parsePositive("-1")) } catch ParseError.notPositive(let number) { print("not positive: \(number)") } catch { print("failed: \(error)") }
class NotANumber(Exception): pass class NotPositive(Exception): def __init__(self, number: int): super().__init__(f"not positive: {number}") self.number = number # No 'throws' in the signature and no 'try' at the call site. Nothing tells a # caller this can fail — and in Python, almost everything can. def parse_positive(text: str) -> int: try: number = int(text) except ValueError: raise NotANumber(text) from None if number <= 0: raise NotPositive(number) return number try: print(parse_positive("42")) print(parse_positive("-1")) except NotPositive as error: print("not positive:", error.number) except Exception as error: print("failed:", error) finally: print("done") # EAFP — "easier to ask forgiveness than permission" — is the house style: # try the thing, and catch the failure, rather than checking first. try: value = {"a": 1}["b"] except KeyError: value = 0 print(value)
The throws annotation is gone, so a function's signature no longer tells you it can fail — and in Python that is a bigger deal than it sounds, because exceptions are used for ordinary control flow: a missing dict key raises, a failed int() raises, and iteration itself ends with a StopIteration. The cultural name for this is EAFP ("easier to ask forgiveness than permission"): the idiomatic move is to attempt the operation and catch the failure, where a Swift developer's instinct is to check first. Both languages have finally, and neither has checked exceptions.
defer becomes a context manager
func process() { print("open") defer { print("close") } // runs on EVERY exit path print("work") } process() // defer is scoped and general: it can run any code on the way out. func guarded() { var count = 0 defer { print("count ended at \(count)") } count += 1 } guarded()
# 'with' is the cleanup construct, and it is tied to a RESOURCE rather than a # scope: the object's __enter__ / __exit__ run on entry and on every exit path. class Resource: def __enter__(self): print("open") return self def __exit__(self, exception_type, exception, traceback): print("close") # runs even if the body raised return False # False = do not swallow the exception with Resource() as resource: print("work") # The everyday use, and why you will type 'with' all day: # with open("file.txt") as handle: # handle.read() ← the file is closed for you, exception or not # contextlib turns a generator into a context manager — the closest thing to a # general-purpose defer. from contextlib import contextmanager @contextmanager def timing(label: str): print(f"start {label}") try: yield finally: print(f"end {label}") with timing("work"): print("doing it")
Python has no defer. Cleanup is attached to a resource rather than a scope: with resource: calls __enter__ on entry and __exit__ on every exit path, including an exception — which is why with open(…) is how every file in Python is opened. For the general case (run this code on the way out, whatever happens), @contextmanager turns a generator into one: everything before the yield is the entry, everything in the finally is the exit. It is more ceremony than defer for a one-off, and better than defer when the cleanup belongs to a thing rather than to a block.
Concurrency & the GIL
async/await, and the GIL underneath
The syntax is the same. The execution model is not: Python's threads cannot run your code in parallel, and this fact organizes everything about concurrency in the language.
import Foundation func fetch(_ id: Int) async -> String { try? await Task.sleep(for: .milliseconds(10)) return "user-\(id)" } // Swift's task group runs on a real thread pool: this is genuinely parallel, // and CPU-bound work in a task uses another core. func fetchAll() async -> [String] { await withTaskGroup(of: String.self) { group in for id in 1...3 { group.addTask { await fetch(id) } } var results: [String] = [] for await result in group { results.append(result) } return results.sorted() } } print(await fetchAll())
import asyncio async def fetch(id: int) -> str: await asyncio.sleep(0.01) # yields to the event loop; does not block it return f"user-{id}" async def fetch_all() -> list[str]: # gather is awaitAll. It runs the coroutines CONCURRENTLY on ONE thread — # they interleave at each await, and none of them runs in parallel. results = await asyncio.gather(*(fetch(id) for id in range(1, 4))) return sorted(results) async def main() -> None: print(await fetch(1)) print(await fetch_all()) asyncio.run(main()) # The GIL: only ONE thread executes Python bytecode at a time. So: # - I/O-bound work → asyncio (or threads): fine, because waiting releases the GIL # - CPU-bound work → multiprocessing: a separate PROCESS per core, with the # arguments pickled across the boundary # Swift's task group parallelizes CPU work for free. Python's does not, and # reaching for threads to speed up computation will make it SLOWER.
The syntax ports one-for-one — async def, await, and asyncio.gather for awaitAll — and then the Global Interpreter Lock changes what it all means. Only one thread runs Python bytecode at a time, so asyncio gives you concurrency (thousands of overlapping I/O waits on a single thread) but never parallelism. CPU-bound work needs multiprocessing, which forks real processes and pickles the arguments across the boundary — closer to Elixir's isolation than to Swift's task group. The rule of thumb: asyncio for waiting, multiprocessing for computing, and threads almost never. (The GIL is finally becoming optional in 3.13+, but assume it is there.)