Singleton Objects: When You Only Need One
Introduction
The singleton pattern ensures that a class has only one instance and provides global access to that instance. In many programming languages, implementing singletons requires careful coding to handle thread safety and lazy initialization. Scala makes this incredibly simple with the object
keyword, which creates singleton instances automatically.
Singleton objects in Scala serve multiple purposes: they can act as utility classes, hold constants, provide factory methods, and serve as application entry points. Understanding when and how to use them effectively is essential for writing idiomatic Scala code.
Basic Object Declaration
Simple Singleton Objects
object MathUtils {
val PI: Double = 3.14159
val E: Double = 2.71828
def square(x: Double): Double = x * x
def cube(x: Double): Double = x * x * x
def factorial(n: Int): BigInt = {
require(n >= 0, "Factorial is not defined for negative numbers")
if (n <= 1) 1 else (2 to n).map(BigInt(_)).product
}
def isPrime(n: Int): Boolean = {
if (n < 2) false
else if (n == 2) true
else if (n % 2 == 0) false
else (3 to math.sqrt(n).toInt by 2).forall(n % _ != 0)
}
}
// Usage - no instantiation needed
println(MathUtils.PI) // 3.14159
println(MathUtils.square(5)) // 25.0
println(MathUtils.factorial(5)) // 120
println(MathUtils.isPrime(17)) // true
Objects with State
object Logger {
private var _logLevel: String = "INFO"
private val logs: scala.collection.mutable.ListBuffer[String] =
scala.collection.mutable.ListBuffer()
def setLogLevel(level: String): Unit = {
require(Set("DEBUG", "INFO", "WARN", "ERROR").contains(level),
"Invalid log level")
_logLevel = level
}
def logLevel: String = _logLevel
private def shouldLog(level: String): Boolean = {
val levels = List("DEBUG", "INFO", "WARN", "ERROR")
levels.indexOf(level) >= levels.indexOf(_logLevel)
}
def debug(message: String): Unit = log("DEBUG", message)
def info(message: String): Unit = log("INFO", message)
def warn(message: String): Unit = log("WARN", message)
def error(message: String): Unit = log("ERROR", message)
private def log(level: String, message: String): Unit = {
if (shouldLog(level)) {
val timestamp = java.time.LocalDateTime.now().toString
val logEntry = s"[$timestamp] [$level] $message"
logs += logEntry
println(logEntry)
}
}
def getLogs: List[String] = logs.toList
def clearLogs(): Unit = logs.clear()
def getLogsSince(since: Int): List[String] = {
logs.takeRight(since).toList
}
}
// Usage
Logger.setLogLevel("WARN")
Logger.debug("This won't be shown") // Below current log level
Logger.info("This won't be shown") // Below current log level
Logger.warn("This is a warning") // Will be shown
Logger.error("This is an error") // Will be shown
println(s"Recent logs: ${Logger.getLogsSince(2)}")
Configuration Objects
object AppConfig {
private val config: scala.collection.mutable.Map[String, String] =
scala.collection.mutable.Map()
// Initialize with default values
locally {
config("app.name") = "MyScalaApp"
config("app.version") = "1.0.0"
config("database.host") = "localhost"
config("database.port") = "5432"
config("cache.enabled") = "true"
config("cache.ttl") = "3600"
}
def get(key: String): Option[String] = config.get(key)
def getString(key: String, default: String = ""): String =
config.getOrElse(key, default)
def getInt(key: String, default: Int = 0): Int =
config.get(key).map(_.toInt).getOrElse(default)
def getBoolean(key: String, default: Boolean = false): Boolean =
config.get(key).map(_.toBoolean).getOrElse(default)
def set(key: String, value: String): Unit = {
config(key) = value
}
def load(properties: Map[String, String]): Unit = {
properties.foreach { case (key, value) => config(key) = value }
}
def getAllSettings: Map[String, String] = config.toMap
def printConfig(): Unit = {
println("Application Configuration:")
config.toSeq.sortBy(_._1).foreach { case (key, value) =>
println(f" $key%-20s = $value")
}
}
}
// Usage
println(AppConfig.getString("app.name")) // MyScalaApp
println(AppConfig.getInt("database.port")) // 5432
println(AppConfig.getBoolean("cache.enabled")) // true
AppConfig.set("feature.beta", "false")
AppConfig.load(Map(
"database.host" -> "production-db.example.com",
"cache.ttl" -> "7200"
))
AppConfig.printConfig()
Companion Objects
Objects Associated with Classes
class BankAccount private(val accountNumber: String, initialBalance: Double) {
private var _balance: Double = initialBalance
def balance: Double = _balance
def deposit(amount: Double): Boolean = {
if (amount > 0) {
_balance += amount
true
} else {
false
}
}
def withdraw(amount: Double): Boolean = {
if (amount > 0 && amount <= _balance) {
_balance -= amount
true
} else {
false
}
}
def transfer(to: BankAccount, amount: Double): Boolean = {
if (withdraw(amount)) {
to.deposit(amount)
true
} else {
false
}
}
override def toString: String = s"Account($accountNumber, balance: $$${_balance})"
}
// Companion object - same name as the class
object BankAccount {
private var accountCounter: Int = 1000
// Factory method
def create(initialBalance: Double): BankAccount = {
accountCounter += 1
new BankAccount(f"ACC$accountCounter%06d", initialBalance)
}
// Alternative factory methods
def createSavings(initialBalance: Double): BankAccount = {
require(initialBalance >= 100, "Savings account requires minimum $100")
val account = create(initialBalance)
println(s"Savings account created: ${account.accountNumber}")
account
}
def createChecking(initialBalance: Double): BankAccount = {
require(initialBalance >= 25, "Checking account requires minimum $25")
val account = create(initialBalance)
println(s"Checking account created: ${account.accountNumber}")
account
}
// Utility methods
def isValidAccountNumber(accountNumber: String): Boolean = {
accountNumber.matches("ACC\\d{6}")
}
def generateRandomAccountNumber(): String = {
val random = scala.util.Random
f"ACC${random.nextInt(900000) + 100000}%06d"
}
}
// Usage
val savings = BankAccount.createSavings(500.0)
val checking = BankAccount.createChecking(100.0)
savings.deposit(250.0)
checking.withdraw(30.0)
savings.transfer(checking, 100.0)
println(savings) // Account(ACC001001, balance: $650.0)
println(checking) // Account(ACC001002, balance: $170.0)
println(BankAccount.isValidAccountNumber("ACC001001")) // true
println(BankAccount.generateRandomAccountNumber()) // ACC123456 (random)
Apply Methods for Factory Pattern
case class Email(address: String) {
require(address.contains("@"), "Invalid email format")
require(!address.startsWith("@") && !address.endsWith("@"),
"Email cannot start or end with @")
def domain: String = address.split("@")(1)
def localPart: String = address.split("@")(0)
def isGmail: Boolean = domain.toLowerCase == "gmail.com"
def isCorporate: Boolean = !List("gmail.com", "yahoo.com", "hotmail.com", "outlook.com")
.contains(domain.toLowerCase)
}
object Email {
// apply method makes object callable like a function
def apply(address: String): Email = {
val cleanAddress = address.trim.toLowerCase
new Email(cleanAddress)
}
// Multiple apply methods for different factory patterns
def apply(localPart: String, domain: String): Email = {
apply(s"$localPart@$domain")
}
// Validation method
def isValid(address: String): Boolean = {
try {
Email(address)
true
} catch {
case _: IllegalArgumentException => false
}
}
// Pattern for common email providers
def gmail(localPart: String): Email = apply(localPart, "gmail.com")
def yahoo(localPart: String): Email = apply(localPart, "yahoo.com")
def outlook(localPart: String): Email = apply(localPart, "outlook.com")
// Bulk validation
def validateAll(addresses: List[String]): (List[Email], List[String]) = {
addresses.foldLeft((List.empty[Email], List.empty[String])) {
case ((valid, invalid), address) =>
if (isValid(address)) {
(valid :+ Email(address), invalid)
} else {
(valid, invalid :+ address)
}
}
}
}
// Usage - apply method allows this syntax
val email1 = Email("john.doe@example.com") // Calls apply method
val email2 = Email("jane", "company.com") // Calls apply(String, String)
val email3 = Email.gmail("alice.smith") // Creates alice.smith@gmail.com
println(email1.domain) // example.com
println(email2.isCorporate) // true
println(email3.isGmail) // true
// Bulk validation
val addresses = List(
"valid@example.com",
"invalid-email",
"another@valid.org",
"@invalid.com"
)
val (validEmails, invalidAddresses) = Email.validateAll(addresses)
println(s"Valid: ${validEmails.map(_.address)}")
println(s"Invalid: $invalidAddresses")
Application Entry Points
Main Methods in Objects
object FileAnalyzer {
def main(args: Array[String]): Unit = {
if (args.length != 1) {
println("Usage: FileAnalyzer <directory-path>")
sys.exit(1)
}
val directoryPath = args(0)
val directory = new java.io.File(directoryPath)
if (!directory.exists() || !directory.isDirectory) {
println(s"Error: '$directoryPath' is not a valid directory")
sys.exit(1)
}
analyzeDirectory(directory)
}
private def analyzeDirectory(directory: java.io.File): Unit = {
val files = Option(directory.listFiles()).getOrElse(Array.empty)
val (directories, regularFiles) = files.partition(_.isDirectory)
val totalSize = regularFiles.map(_.length()).sum
// Group files by extension
val filesByExtension = regularFiles
.groupBy(getFileExtension)
.view.mapValues(_.length)
.toMap
.toSeq
.sortBy(-_._2)
// Find largest files
val largestFiles = regularFiles
.sortBy(-_.length())
.take(5)
printReport(directory.getName, directories.length, regularFiles.length,
totalSize, filesByExtension, largestFiles)
}
private def getFileExtension(file: java.io.File): String = {
val name = file.getName
val lastDot = name.lastIndexOf('.')
if (lastDot > 0) name.substring(lastDot + 1).toLowerCase else "no extension"
}
private def formatFileSize(bytes: Long): String = {
val kb = bytes / 1024.0
val mb = kb / 1024.0
val gb = mb / 1024.0
if (gb >= 1) f"${gb}%.2f GB"
else if (mb >= 1) f"${mb}%.2f MB"
else if (kb >= 1) f"${kb}%.1f KB"
else s"$bytes bytes"
}
private def printReport(dirName: String, dirCount: Int, fileCount: Int,
totalSize: Long, filesByExtension: Seq[(String, Int)],
largestFiles: Array[java.io.File]): Unit = {
println(s"=== Directory Analysis: $dirName ===")
println(f"Directories: $dirCount%,d")
println(f"Files: $fileCount%,d")
println(s"Total Size: ${formatFileSize(totalSize)}")
println()
if (filesByExtension.nonEmpty) {
println("Files by Extension:")
filesByExtension.take(10).foreach { case (ext, count) =>
println(f" .$ext%-15s: $count%,d files")
}
println()
}
if (largestFiles.nonEmpty) {
println("Largest Files:")
largestFiles.foreach { file =>
val size = formatFileSize(file.length())
println(f" ${file.getName}%-40s: $size")
}
}
}
}
Using App Trait (Alternative to main)
object WebServerConfig extends App {
// Command line argument parsing
if (args.length < 2) {
println("Usage: WebServerConfig <port> <environment>")
println("Environment: development | staging | production")
sys.exit(1)
}
val port = args(0).toInt
val environment = args(1).toLowerCase
if (!Set("development", "staging", "production").contains(environment)) {
println("Invalid environment. Use: development, staging, or production")
sys.exit(1)
}
// Configuration based on environment
val config = environment match {
case "development" => Map(
"database.host" -> "localhost",
"database.name" -> "myapp_dev",
"cache.enabled" -> "false",
"log.level" -> "DEBUG"
)
case "staging" => Map(
"database.host" -> "staging-db.company.com",
"database.name" -> "myapp_staging",
"cache.enabled" -> "true",
"log.level" -> "INFO"
)
case "production" => Map(
"database.host" -> "prod-db.company.com",
"database.name" -> "myapp_prod",
"cache.enabled" -> "true",
"log.level" -> "WARN"
)
}
// Generate configuration file
val configContent = s"""# Web Server Configuration
|# Environment: $environment
|# Generated: ${java.time.LocalDateTime.now()}
|
|server.port=$port
|server.environment=$environment
|
|${config.map { case (k, v) => s"$k=$v" }.mkString("\n")}
|""".stripMargin
val configFile = new java.io.File(s"config-$environment.properties")
val writer = new java.io.PrintWriter(configFile)
try {
writer.write(configContent)
println(s"Configuration written to: ${configFile.getAbsolutePath}")
} finally {
writer.close()
}
// Display summary
println(s"""
|Configuration Summary:
|Environment: $environment
|Port: $port
|Database: ${config("database.host")}/${config("database.name")}
|Cache: ${config("cache.enabled")}
|Log Level: ${config("log.level")}
|""".stripMargin)
}
Advanced Patterns with Objects
Registry Pattern
trait Renderer {
def render(content: String): String
def mimeType: String
}
class HTMLRenderer extends Renderer {
def render(content: String): String = s"<html><body>$content</body></html>"
def mimeType: String = "text/html"
}
class JSONRenderer extends Renderer {
def render(content: String): String = s"""{"content": "$content"}"""
def mimeType: String = "application/json"
}
class XMLRenderer extends Renderer {
def render(content: String): String = s"<content>$content</content>"
def mimeType: String = "application/xml"
}
object RendererRegistry {
private val renderers: scala.collection.mutable.Map[String, Renderer] =
scala.collection.mutable.Map()
// Register default renderers
locally {
register("html", new HTMLRenderer)
register("json", new JSONRenderer)
register("xml", new XMLRenderer)
}
def register(name: String, renderer: Renderer): Unit = {
renderers(name.toLowerCase) = renderer
println(s"Registered renderer: $name (${renderer.mimeType})")
}
def get(name: String): Option[Renderer] = {
renderers.get(name.toLowerCase)
}
def getOrDefault(name: String): Renderer = {
get(name).getOrElse(get("html").get) // Default to HTML
}
def availableRenderers: List[String] = renderers.keys.toList.sorted
def renderContent(content: String, format: String): (String, String) = {
val renderer = getOrDefault(format)
(renderer.render(content), renderer.mimeType)
}
def listRenderers(): Unit = {
println("Available Renderers:")
renderers.foreach { case (name, renderer) =>
println(f" $name%-10s -> ${renderer.mimeType}")
}
}
}
// Usage
RendererRegistry.listRenderers()
val content = "Hello, World!"
val (htmlOutput, htmlMimeType) = RendererRegistry.renderContent(content, "html")
val (jsonOutput, jsonMimeType) = RendererRegistry.renderContent(content, "json")
println(s"HTML ($htmlMimeType): $htmlOutput")
println(s"JSON ($jsonMimeType): $jsonOutput")
// Register custom renderer
class PlainTextRenderer extends Renderer {
def render(content: String): String = content
def mimeType: String = "text/plain"
}
RendererRegistry.register("text", new PlainTextRenderer)
Cache Object Pattern
object Cache {
private val cache: scala.collection.mutable.Map[String, (Any, Long)] =
scala.collection.mutable.Map()
private val defaultTTL: Long = 5 * 60 * 1000 // 5 minutes in milliseconds
def put[T](key: String, value: T, ttlMs: Long = defaultTTL): Unit = {
val expiryTime = System.currentTimeMillis() + ttlMs
cache(key) = (value, expiryTime)
}
def get[T](key: String): Option[T] = {
cache.get(key) match {
case Some((value, expiryTime)) =>
if (System.currentTimeMillis() <= expiryTime) {
Some(value.asInstanceOf[T])
} else {
cache.remove(key) // Remove expired entry
None
}
case None => None
}
}
def getOrElse[T](key: String, default: => T): T = {
get[T](key).getOrElse(default)
}
def getOrCompute[T](key: String, compute: => T, ttlMs: Long = defaultTTL): T = {
get[T](key) match {
case Some(value) => value
case None =>
val computed = compute
put(key, computed, ttlMs)
computed
}
}
def remove(key: String): Boolean = {
cache.remove(key).isDefined
}
def clear(): Unit = {
cache.clear()
}
def size: Int = {
cleanupExpired()
cache.size
}
def keys: Set[String] = {
cleanupExpired()
cache.keySet.toSet
}
private def cleanupExpired(): Unit = {
val currentTime = System.currentTimeMillis()
val expiredKeys = cache.filter { case (_, (_, expiryTime)) =>
currentTime > expiryTime
}.keys
expiredKeys.foreach(cache.remove)
}
def stats(): String = {
cleanupExpired()
s"Cache Stats: ${cache.size} entries, Keys: ${keys.mkString(", ")}"
}
}
// Usage example
def expensiveComputation(n: Int): BigInt = {
println(s"Computing factorial of $n...")
Thread.sleep(1000) // Simulate expensive operation
(1 to n).map(BigInt(_)).product
}
// First call - computed and cached
val result1 = Cache.getOrCompute("factorial_10", expensiveComputation(10))
println(s"First call: $result1")
// Second call - retrieved from cache (much faster)
val result2 = Cache.getOrCompute("factorial_10", expensiveComputation(10))
println(s"Second call: $result2")
println(Cache.stats())
// Manual cache operations
Cache.put("user_123", "John Doe", 2000) // 2 second TTL
println(Cache.get[String]("user_123"))
Thread.sleep(2500) // Wait for expiration
println(Cache.get[String]("user_123")) // None - expired
Practical Examples
URL Builder Singleton
object URLBuilder {
private var baseURL: String = "https://api.example.com"
private var apiVersion: String = "v1"
private val defaultHeaders: scala.collection.mutable.Map[String, String] =
scala.collection.mutable.Map("Content-Type" -> "application/json")
def setBaseURL(url: String): URLBuilder.type = {
baseURL = url.replaceAll("/+$", "") // Remove trailing slashes
this
}
def setAPIVersion(version: String): URLBuilder.type = {
apiVersion = version
this
}
def addDefaultHeader(key: String, value: String): URLBuilder.type = {
defaultHeaders(key) = value
this
}
case class URL(
path: String,
queryParams: Map[String, String] = Map(),
headers: Map[String, String] = Map()
) {
def withParam(key: String, value: String): URL = {
copy(queryParams = queryParams + (key -> value))
}
def withParams(params: Map[String, String]): URL = {
copy(queryParams = queryParams ++ params)
}
def withHeader(key: String, value: String): URL = {
copy(headers = headers + (key -> value))
}
def build(): String = {
val queryString = if (queryParams.nonEmpty) {
"?" + queryParams.map { case (k, v) => s"$k=$v" }.mkString("&")
} else ""
s"$baseURL/$apiVersion/$path$queryString"
}
def getAllHeaders(): Map[String, String] = {
defaultHeaders.toMap ++ headers
}
}
def url(path: String): URL = {
URL(path.replaceAll("^/+", "")) // Remove leading slashes
}
// Predefined endpoints
def users: URL = url("users")
def user(id: String): URL = url(s"users/$id")
def posts: URL = url("posts")
def post(id: String): URL = url(s"posts/$id")
def search: URL = url("search")
}
// Usage
URLBuilder
.setBaseURL("https://jsonplaceholder.typicode.com")
.setAPIVersion("") // No version for this API
.addDefaultHeader("Authorization", "Bearer token123")
val usersURL = URLBuilder.users
.withParam("page", "2")
.withParam("limit", "10")
.build()
val userURL = URLBuilder.user("123")
.withHeader("Accept", "application/json")
.build()
val searchURL = URLBuilder.search
.withParams(Map("q" -> "scala", "type" -> "post"))
.build()
println(usersURL) // https://jsonplaceholder.typicode.com/users?page=2&limit=10
println(userURL) // https://jsonplaceholder.typicode.com/users/123
println(searchURL) // https://jsonplaceholder.typicode.com/search?q=scala&type=post
Summary
In this lesson, you've explored Scala's singleton objects and learned how to use them effectively:
✅ Object Declaration: Creating singleton instances with the object
keyword
✅ Companion Objects: Factory methods and utilities associated with classes
✅ Apply Methods: Making objects callable like functions
✅ Application Entry Points: Using main
methods and the App
trait
✅ Design Patterns: Registry, cache, and configuration patterns
✅ State Management: Handling mutable state in singleton objects
Singleton objects are one of Scala's most powerful features, providing a clean way to implement common patterns without the complexity found in other languages.
What's Next
In the next lesson, we'll explore "Companion Objects: The Perfect Partnership." You'll learn how companion objects and classes work together to provide clean APIs, factory methods, and access to private members.
This will deepen your understanding of one of Scala's most elegant features for organizing code and creating intuitive interfaces.
Ready to explore companion objects? Let's continue!
Comments
Be the first to comment on this lesson!