All posts Software

Swift Through the Eyes of Someone Coming from Java

I moved from Java to Swift in 2021, and what I wrote in those first weeks was really Java dressed up in Swift syntax. Through my own mistakes, I go through exactly where each of my old reflexes misfired: Optionals, value types, dispatch behavior in protocol extensions, ARC, and closure capture lists.

DT
Demir Taşdemir Mobile App & Web Developer
— min read

In 2020 I was writing Android apps in Java; in 2021 I moved to Swift. What I wrote in those first weeks wasn't really Swift — it was Java written in Swift syntax. The compiler stopped me in most places, and where it didn't, the app crashed at runtime. This post is a record of which of my Java reflexes misfired, and exactly where.

An Optional is not a fancy null check

In Java, null is a silent possibility behind every reference. You can't tell from a type signature whether a value might come back empty; you learn that either from the docs or from a NullPointerException. In Swift, String and String? are two different types. The second one is really an enum: either .some(value) or .none. That's why the compiler won't let you use a possibly-empty value without unwrapping it first.

My first reflex was the exclamation mark. In Java I had gotten used to thinking "this is definitely populated" and skipping the check; in Swift I wrote that same thought as !, and the app shut down with "Unexpectedly found nil while unwrapping an Optional value." Force unwrapping doesn't remove the check; it defers the check to runtime and chooses to crash when it fails.

// The habit from the Java side
      String ad = kullanici.getAd();
      if (ad != null && !ad.isEmpty()) { ... }

      // The Swift equivalent
      guard let ad = kullanici.ad, !ad.isEmpty else { return }
      // from here on, ad is a plain String, not an Optional

Today I use guard let almost everywhere. The reason isn't aesthetic: because guard forces an early exit, the unwrapped value stays usable in the same scope and the code doesn't turn into nested if blocks. For chained access ?. and for default values ?? cover most cases.

The one reasonable exception turned out to be Storyboard connections: @IBOutlet properties are declared with !, because the object is wired up when the interface loads, not during init. Seeing that and generalizing it into "so the exclamation mark is fine after all" was my mistake in those first months.

Two marks in code that ships

! and try! are the code form of saying "this will never happen." When it does happen, the app shuts down in the user's hands. I keep them only for assumptions where, the moment they fail, there is no way to continue anyway; everywhere else I write an explicit error path.

Value types versus reference types

In Java, everything except the primitive types is a reference. In Swift, struct, enum and tuples are value types; they are copied when assigned or passed into a function. Array, Dictionary and String are structs too. If you pass an array into a function and modify it there, the caller's array doesn't change — for someone coming from Java, this is the most surprising behavior of all.

let isn't an exact match for Java's final either. When you hold a class instance with let, what gets fixed is the reference, not the contents. When you hold a struct with let, everything is fixed:

struct Sepet { var urunler: [String] = [] }
      let sepet = Sepet()
      // sepet.urunler.append("kalem")  // doesn't compile: the struct is fully constant

      final class SepetRef { var urunler: [String] = [] }
      let sepetRef = SepetRef()
      sepetRef.urunler.append("kalem")  // compiles: the reference is constant, the contents aren't

For the same reason, a struct method that modifies its own fields has to be marked mutating. The cost of copying was the first question that came to mind; the standard library collections work with copy-on-write, meaning that when a copy is made the underlying buffer is shared, and it is only really duplicated once one of the sides tries to write.

The gain that helps me most day to day is this: for structs, the compiler synthesizes Equatable, Hashable and Codable conformances in most cases. The equals/hashCode pairs and the JSON mapping code I used to write by hand in Java become a single line here. I got a lot of use out of this while turning Firestore documents into models.

When do I pick a class? When the object's identity matters — that is, when the same instance needs to be shared from several places; when I'm subclassing UIKit classes; or when I need deinit. Everything else is a struct.

Protocols, a little beyond interfaces

The first time I saw a protocol I said "this is an interface," and in most places I wasn't wrong. The difference starts with extensions: you can write default behavior onto a protocol. Java 8's default methods do a similar job, but in Swift these extensions apply to structs and enums just as much as to classes — and on top of that, you can make a type you didn't write conform to a protocol after the fact.

What really misled me was the dispatch behavior:

protocol Fiyatli { var fiyat: Decimal { get } }

      extension Fiyatli {
          func etiket() -> String { "\(fiyat) TL" }
      }

      struct Urun: Fiyatli {
          var fiyat: Decimal
          func etiket() -> String { "kampanya: \(fiyat) TL" }
      }

      let u: Fiyatli = Urun(fiyat: 100)
      u.etiket()  // "100 TL" — not Urun's method, but the one in the extension

Because the method isn't declared on the protocol itself, the call is resolved at compile time based on the variable's static type. Since virtual dispatch is the default in Java, I took this behavior for a bug and burned hours on it. The rule is simple: if you want conforming types to be able to override it, declare the method on the protocol as well as in the extension.

The second topic is associated types. You can't use a protocol containing an associatedtype directly as a variable type; this is where the some and any keywords come in. Approaching it with my Java generic-interface habits, it took me a while to understand why the compiler was objecting.

ARC is not a garbage collector

The JVM has a garbage collector that collects objects once they become unreachable, even when there are cyclic references between them. Swift has ARC: the compiler inserts the calls that increment and decrement the reference count into the code, and the object is released immediately once the count drops to zero. Being deterministic is good, but nobody collects reference cycles.

I hit my first retain cycle in a Firestore listener. When I closed the screen, deinit never ran, and when I went back and entered again, two listeners fired at once for the same data. The cause was that the block captured self strongly while the object held on to the listener:

final class SiparisDinleyici {
          private var kayit: ListenerRegistration?

          func basla() {
              kayit = db.collection("siparisler")
                  .addSnapshotListener { [weak self] anlik, hata in
                      guard let self else { return }
                      self.guncelle(anlik)
                  }
          }

          deinit { kayit?.remove() }
      }

Two habits came out of this: declaring delegate properties as weak var, and cancelling listener registrations when the screen closes. Xcode's Memory Graph Debugger shows which object is holding which, but the fastest way to diagnose it is still to put a print in the class's deinit and close the screen.

The difference between weak and unowned is this: weak is optional, and becomes nil once the target goes away. unowned never becomes nil; if you access it after the target is gone, the app crashes. Anywhere I'm not certain the lifetimes really are nested, I use weak.

Closure capture lists

In Java, a local variable you use inside a lambda has to be "effectively final," and it is captured by value. In Swift, a closure captures the variable itself: you can modify an outer var from inside the closure, and the change is visible outside too. A capture list, on the other hand, is evaluated at the moment the closure is created and takes a copy:

var sayac = 0
      let a = { [sayac] in print(sayac) }  // copied at creation time
      let b = { print(sayac) }             // the variable itself is captured
      sayac = 5
      a()  // 0
      b()  // 5

Then there's @escaping: if the closure will be stored and called after the function returns, you have to say so in the signature. Because Firebase's completion blocks work this way, the risk of capturing self strongly arises exactly there. On network and database calls, my reflex is now to write [weak self] straight away.

Caught between UIKit and SwiftUI

When I moved to Swift, the UI framework I learned was UIKit: the view lifecycle, Auto Layout, the delegate and dataSource patterns. Having wrestled with the Activity and Fragment lifecycles on Android made this easier, because both ask the same question: at which moment should this work happen?

Moving to SwiftUI, the real difficulty wasn't the syntax but the way of thinking. In UIKit you update the screen; in SwiftUI you update the state and the screen redraws itself. Where I got stuck most was ownership: if the screen itself creates an observable object you use @StateObject, and if it receives it from outside you use @ObservedObject. When I mixed these up, the object was recreated from scratch on every redraw and the state silently reset.

The transition period wasn't one-directional either. The SDK I used while building the video call screen was UIKit-based; to bring it into a SwiftUI screen I had to wrap it with UIViewControllerRepresentable. The reverse is possible too: a SwiftUI screen can be embedded into an existing UIKit project with UIHostingController. In my apps, the two ran side by side for a long time.

Then there's the question of the minimum iOS version you'll support. Since a significant portion of the SwiftUI APIs arrived in specific versions, once I wanted to support older devices too, leaving some screens in UIKit turned out to be the most practical solution. UIKit knowledge doesn't go to waste either; when SwiftUI behaves unexpectedly, I still look down there to understand what's going on underneath.

A short list for anyone coming from Java

  • Don't brush Optionals aside. Write guard let instead of !; the exclamation mark is only for situations where there really is no way to continue.
  • Accept that struct is the default. Pick a class when you need identity or deinit.
  • Don't equate let with final. With classes, what's fixed is the reference, not the contents.
  • A method in a protocol extension isn't always overridable. If you want it overridden, declare it on the protocol too.
  • Memory is your responsibility. Delegates weak, listeners cancelled on close.
  • Don't capture self strongly in escaping closures. Make [weak self] a habit.

Looking back, what made Swift hard wasn't the language itself but the assumptions I brought over from Java. Everywhere the compiler objected, understanding what it was trying to protect — instead of asking "why is this language like this" — was the one thing that shortened my learning time. Even though I've moved toward Flutter and Dart in 2026, none of this knowledge went to waste whenever I had to drop down to the platform layer.

  • Swift
  • iOS
  • Java
  • SwiftUI
  • Software
Share: LinkedIn X WhatsApp
DT

Demir Taşdemir

Mobile App & Web Developer

I have been building software since 2018. I have published 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.

Have an idea on the mobile side?

Take a look at the apps I have shipped with Swift, Java, Kotlin and Flutter, and write to me directly about your project.