To compare ergonomics and performance of some languages for my hobby projects, I have written a simple lines of code counter in Swift, Go, Rust and Python. It scans a given directory tree for source code files and then prints a summary of the number of files, lines of code and lines of comments.
The result surprised me: Swift was by far the slowest of them all.
Am I doing something obvious wrong, or is Swift generally not a good language for this task?
Scanning my entire development directory on macOS 15.5 (M1 Max):
- Swift: 2.6s (compiled with
swiftc -O -o loc main.swift, Swift 6.1.2) - Python: 1.0s (source: https://pastebin.com/rpPXTvWg)
- Go: 0.5s (source https://pastebin.com/TGn1Hb7t)
- Rust: 0.4s (source: https://pastebin.com/wBkBytt6)
These are all "naive" implementations without any form of optimization on my part. I suspect Go did some parallelization automatically, because it showed more than 100% CPU usage when timeing it.
main.swift:
import Foundation
struct StandardError: TextOutputStream, Sendable {
private static let handle = FileHandle.standardError
public func write(_ string: String) {
Self.handle.write(Data(string.utf8))
}
}
var stderr = StandardError()
// don't scan these directories
let IGNORED_DIRS = [
"__pycache__",
"node_modules",
]
// mapping source file type to comment strings
let FILE_TYPES = [
"c": ("//", "/*", "*/"),
"c++": ("//", "/*", "*/"),
"cpp": ("//", "/*", "*/"),
"go": ("//", "/*", "*/"),
"h": ("//", "/*", "*/"),
"hs": ("--", "{-", "-}"),
"js": ("//", "/*", "*/"),
"kt": ("//", "/*", "*/"),
"pro": ("%", "/*", "*/"),
"ps1": ("#", "<#", "#>"),
"py": ("#", "", "" ),
"rs": ("//", "/*", "*/"),
"sql": ("--", "/*", "*/"),
"svelte": ("//", "/*", "*/"),
"swift": ("//", "/*", "*/"),
"ts": ("//", "/*", "*/"),
]
struct Counts {
var files = 0
var code = 0
var comments = 0
}
extension Counts: CustomStringConvertible {
var description: String {
String(format: "%6d %16d %20d", files, code, comments)
}
}
func handleScanError(url: URL, error: Error) -> Bool {
print("Error: Couldn't scan directory '\(url)': \(error)", to: &stderr)
return true
}
func isSourceFile(_ file: URL) -> Bool {
FILE_TYPES.keys.contains(file.pathExtension)
}
if CommandLine.argc < 2 {
print("Usage: loc <file or directory>...", to: &stderr)
exit(1)
}
// collect source files
let clock = ContinuousClock()
var start = clock.now
var sourceFiles = Set<String>()
let fm = FileManager.default
for arg in CommandLine.arguments[1...] {
let root = URL(filePath: arg)
guard (try? root.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true else {
if isSourceFile(root) {
sourceFiles.insert(arg)
} else {
print("Warning: '\(arg)' is not a source file", to: &stderr)
}
continue
}
guard let directoryEnumerator = fm.enumerator(at: root, includingPropertiesForKeys: nil, errorHandler: handleScanError) else {
print("Couldn't enumerate directory '\(root)'", to: &stderr)
continue
}
for case let path as URL in directoryEnumerator {
guard let resourceValues = try? path.resourceValues(forKeys: [.nameKey, .isDirectoryKey]), let isDirectory = resourceValues.isDirectory, let name = resourceValues.name else {
print("Couldn't get resource values for '\(path)'", to: &stderr)
continue
}
if isDirectory {
// skip unwanted dirs
if name.starts(with: ".") || IGNORED_DIRS.contains(name) {
directoryEnumerator.skipDescendants()
}
} else {
if isSourceFile(path) {
sourceFiles.insert(path.path(percentEncoded: false))
}
}
}
}
var end = clock.now
print("DEBUG: Total collecting all source files: \(end - start)")
// scan files
start = clock.now
var parseDuration = Duration.zero
var scanDuration = Duration.zero
var counts: [String: Counts] = [:]
for file in sourceFiles {
let ext = URL(filePath: file).pathExtension
let (commentLine, commentStart, commentEnd) = FILE_TYPES[ext]!
let hasBlockComments = commentStart != "" && commentEnd != ""
var isBlockComment = false
let time1 = clock.now
guard let text = try? String(contentsOfFile: file, encoding: .utf8) else {
print("Error: failed to read file as utf8 '\(file)'", to: &stderr)
continue
}
var c = counts[ext] ?? Counts()
c.files += 1
let time2 = clock.now
for line in text.components(separatedBy: .newlines) {
let l = line.trimmingCharacters(in: .whitespaces)
if l.isEmpty {
continue
}
if hasBlockComments {
if isBlockComment {
c.comments += 1
if l.contains(commentEnd) {
isBlockComment = false
}
continue
}
if l.hasPrefix(commentStart) {
c.comments += 1
if !l.contains(commentEnd) {
isBlockComment = true
}
continue
}
}
if l.hasPrefix(commentLine) {
c.comments += 1
continue
}
c.code += 1
}
counts[ext] = c
let time3 = clock.now
parseDuration += time2 - time1
scanDuration += time3 - time2
}
end = clock.now
// print results
print(" files lines of code lines of comments")
var totals = Counts()
for (ext, c) in counts {
print(".\(ext.padding(toLength: 8, withPad: " ", startingAt: 0)) \(c)")
totals.files += c.files
totals.code += c.code
totals.comments += c.comments
}
print("──────────────────────────────────────────────────────")
print("total \(totals)")
print("DEBUG: Reading files as UTF8: \(parseDuration)")
print("DEBUG: Scanning line by line: \(scanDuration)")
print("DEBUG: Total iterating over source files: \(end - start)")
Output:
> swiftc -O -o loc main.swift
> time ./loc ~/Developer
DEBUG: Collecting files: 0.26490225 seconds
files lines of code lines of comments
.ps1 4 131 90
.swift 428 20685 1696
.svelte 136 6376 671
.pro 4 194 9
.go 95 8624 411
.sql 1 8 0
.js 105 33755 4062
.rs 185 14857 540
.kt 15 566 102
.cpp 6 761 197
.h 130 204362 34138
.hs 4 130 21
.py 5781 1873424 137586
.ts 42 14030 367
.c 166 211577 45105
──────────────────────────────────────────────────────
total 7102 2389480 224995
DEBUG: Reading files as UTF8: 0.148959414 seconds
DEBUG: Scanning line by line: 2.191142604 seconds
DEBUG: Total iterating over source files: 2.357491542 seconds
./loc ~/Developer 2.38s user 0.25s system 99% cpu 2.634 total
Edit: adding source code for the other implementations for comparison. Again: these are all unoptimized, "naive" implementations. I kept the structure and logic of all programs as close to each other as possible.
- Python: https://pastebin.com/rpPXTvWg
- Go: https://pastebin.com/TGn1Hb7t
- Rust: https://pastebin.com/wBkBytt6
Edit 2:
In my experiments, using fopen() and getline(), as per Martin R's answer, was the only thing that made the Swift program faster. On my machine Swift now takes 1.6s (Python 1.0s).
For me, the conclusion is that Swift is not worth it for my projects. I can get similar performance by writing trivial Python code (I had to do no performance-troubleshooting at all there). Or I can write Go code which is still relatively simple and much faster (and probably has potential to be made even faster if it's needed). It makes me a bit sad, because I enjoy writing Swift a lot.
for line in text.components ...loop. \$\endgroup\$