Swifty Journey

Swift de Cero a Experto #9: Propiedades, métodos y subscripts

Stored vs computed properties, observers, lazy, static. Cómo las propiedades definen el layout en memoria y por qué computed = zero storage.

En el artículo anterior descubrimos que structs viven en el stack y classes en el heap. Hoy entramos al tejido conectivo de ambos: propiedades, métodos y subscripts — las piezas que definen qué sabe tu tipo, qué puede hacer, y cómo accedes a sus datos.

No todas las propiedades cuestan lo mismo. Una stored property ocupa espacio real en el layout de memoria de tu tipo. Una computed property no ocupa ni un byte — es una función disfrazada de propiedad. Entender esa diferencia es la clave para diseñar tipos eficientes.

Una stored property ocupa bytes reales en cada instancia. Una computed property ocupa cero — es simplemente una función con sintaxis de propiedad.

El ave Swift organizando propiedades en una estructura de datos

Stored properties: el dato real

struct FixedLengthRange {
var firstValue: Int // 8 bytes — stored
let length: Int // 8 bytes — stored (inmutable)
}
// MemoryLayout<FixedLengthRange>.size == 16 bytes (8 + 8)
var range = FixedLengthRange(firstValue: 0, length: 3)
range.firstValue = 6 // OK — var
// range.length = 4 // ERROR — let es inmutable

Recuerda del artículo #8: si el struct se asigna a una constante let, ninguna de sus propiedades puede cambiar — ni siquiera las var:

let fixedRange = FixedLengthRange(firstValue: 0, length: 3)
// fixedRange.firstValue = 6 // ERROR — todo el struct es inmutable

Con classes es diferente — let solo protege la referencia, no el objeto:

class SomeClass {
var value = 42
}
let instance = SomeClass()
instance.value = 99 // OK — let protege la referencia, no las propiedades

Lazy stored properties: alocación diferida

class DataManager {
lazy var importer = DataImporter()
var data: [String] = []
}
let manager = DataManager()
// importer NO se ha creado todavía — zero costo hasta que lo necesites
manager.data.append("Some data")
print(manager.importer.filename)
// AHORA se crea DataImporter — solo cuando realmente lo necesitas

Línea de tiempo de una lazy property: la instancia se crea con importer aún en nil a costo cero, sigue en nil mientras no se toca, y DataImporter() se ejecuta solo en el primer acceso

¿Cuándo usar lazy?

  • El valor depende de factores externos que no se conocen hasta después del init
  • El cálculo inicial es costoso y quizás nunca se necesite
  • La propiedad depende de self (que no existe durante la inicialización)

Computed properties: funciones disfrazadas

struct Rect {
var origin: Point
var size: Size
var center: Point {
get {
Point(
x: origin.x + (size.width / 2),
y: origin.y + (size.height / 2)
)
}
set {
origin.x = newValue.x - (size.width / 2)
origin.y = newValue.y - (size.height / 2)
}
}
}
var square = Rect(
origin: Point(x: 0, y: 0),
size: Size(width: 10, height: 10)
)
print(square.center) // Point(x: 5, y: 5)
square.center = Point(x: 15, y: 15)
print(square.origin) // Point(x: 10, y: 10)

center no ocupa espacio — se calcula cada vez. Si solo tienes getter, puedes simplificar:

struct Cuboid {
var width = 0.0, height = 0.0, depth = 0.0
var volume: Double { // read-only computed
width * height * depth
}
}
Stored vs Computed — en memoria
  • Stored property → ocupa bytes reales en cada instancia. Determina MemoryLayout.size
  • Computed property → zero bytes. Es una función que el compilador puede inlinear
  • Lazy stored → ocupa los bytes del valor más una pequeña cantidad de estado extra para rastrear si ya se inicializó (definido por la implementación)
  • Truco del compilador: si una computed property es simple, el compilador la hace inline (inline = el compilador pega el cuerpo de la función directamente en el lugar de la llamada, eliminando el costo de la llamada a función) — equivalente a acceder directamente al campo

Comparación de los cuatro tipos de almacenamiento de propiedades — stored, lazy stored, computed, observers — mostrando su huella relativa en bytes dentro de la instancia: stored cuesta N bytes, lazy stored N más 1 byte de flag, computed y observers cero

Property observers: willSet y didSet

class StepCounter {
var totalSteps: Int = 0 {
willSet(newTotalSteps) {
print("A punto de cambiar a \(newTotalSteps)")
}
didSet {
if totalSteps > oldValue {
print("Agregaste \(totalSteps - oldValue) pasos")
}
}
}
}
let counter = StepCounter()
counter.totalSteps = 200
// "A punto de cambiar a 200"
// "Agregaste 200 pasos"
counter.totalSteps = 360
// "A punto de cambiar a 360"
// "Agregaste 160 pasos"
  • willSet recibe el nuevo valor como parámetro (default: newValue)
  • didSet tiene acceso al valor anterior (default: oldValue)
  • Puedes usar uno, ambos, o ninguno

Los observers también funcionan en propiedades heredadas — puedes agregar willSet/didSet a una propiedad que heredas de una superclass.

Type properties: static y class

struct AudioChannel {
static let thresholdLevel = 10
static var maxInputLevelForAllChannels = 0
var currentLevel: Int = 0 {
didSet {
if currentLevel > AudioChannel.thresholdLevel {
currentLevel = AudioChannel.thresholdLevel
}
if currentLevel > AudioChannel.maxInputLevelForAllChannels {
AudioChannel.maxInputLevelForAllChannels = currentLevel
}
}
}
}
var leftChannel = AudioChannel()
leftChannel.currentLevel = 7
print(AudioChannel.maxInputLevelForAllChannels) // 7 — compartido

Property wrappers: reutiliza la lógica

Una nota antes del código: 0...100 es un ClosedRange<Int> — un rango que incluye ambos extremos; .lowerBound y .upperBound devuelven su primer y último valor.

@propertyWrapper
struct Clamped {
var wrappedValue: Int {
didSet { wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound) }
}
let range: ClosedRange<Int>
init(wrappedValue: Int, _ range: ClosedRange<Int>) {
self.range = range
self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
}
struct Player {
@Clamped(0...100) var health: Int = 100
@Clamped(0...999) var score: Int = 0
}
var player = Player()
player.health = 150 // se clampea a 100
player.health = -10 // se clampea a 0

wrappedValue es el nombre obligatorio que Swift busca: leer o escribir player.health pasa por el wrappedValue del wrapper, y @Clamped(0...100) pasa 0...100 al init del wrapper.

SwiftUI está lleno de property wrappers: @State, @Binding, @ObservedObject, @Environment. Ahora sabes cómo funcionan bajo el capó.

Métodos de instancia

Los métodos son funciones que pertenecen a un tipo:

class Counter {
var count = 0
func increment() {
count += 1
}
func increment(by amount: Int) {
count += amount
}
func reset() {
count = 0
}
}
let counter = Counter()
counter.increment()
counter.increment(by: 5)
print(counter.count) // 6
counter.reset()

Cada método de instancia tiene un parámetro implícito self que referencia a la instancia. Normalmente se omite a menos que haya ambigüedad:

struct Point {
var x = 0.0, y = 0.0
func isToTheRightOf(x: Double) -> Bool {
return self.x > x // self.x es la propiedad, x es el parámetro
}
}

mutating en structs y enums

Como vimos en el artículo #8, los value types (structs y enums) necesitan mutating para modificar self:

struct Point {
var x = 0.0, y = 0.0
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
x += deltaX
y += deltaY
}
}
// Incluso puedes reemplazar self completamente:
enum TriStateSwitch {
case off, low, high
mutating func next() {
switch self {
case .off: self = .low
case .low: self = .high
case .high: self = .off
}
}
}

El capibara modificando un struct con la keyword mutating

Type methods: static y class

Los type methods se llaman sobre el tipo, no sobre una instancia:

struct LevelTracker {
static var highestUnlockedLevel = 1
var currentLevel = 1
static func unlock(_ level: Int) {
if level > highestUnlockedLevel {
highestUnlockedLevel = level
}
}
static func isUnlocked(_ level: Int) -> Bool {
return level <= highestUnlockedLevel
}
// @discardableResult: calling this method and ignoring its Bool return value won't trigger a compiler warning
@discardableResult
mutating func advance(to level: Int) -> Bool {
if LevelTracker.isUnlocked(level) {
currentLevel = level
return true
}
return false
}
}
LevelTracker.unlock(2)
var tracker = LevelTracker()
tracker.advance(to: 2) // true

En classes, usa class func en lugar de static func cuando necesites que subclasses puedan hacerle override — más sobre eso en el #10.

Subscripts: acceso con corchetes

En el ejemplo de abajo, assert(...) hace crashear el programa durante el desarrollo si su condición es falsa — aquí protege contra índices fuera de rango:

struct Matrix {
let rows: Int, columns: Int
var grid: [Double]
init(rows: Int, columns: Int) {
self.rows = rows
self.columns = columns
grid = Array(repeating: 0.0, count: rows * columns)
}
subscript(row: Int, column: Int) -> Double {
get {
assert(row >= 0 && row < rows && column >= 0 && column < columns)
return grid[(row * columns) + column]
}
set {
assert(row >= 0 && row < rows && column >= 0 && column < columns)
grid[(row * columns) + column] = newValue
}
}
}
var matrix = Matrix(rows: 2, columns: 2)
matrix[0, 1] = 1.5
matrix[1, 0] = 3.2
print(matrix[0, 1]) // 1.5

Los subscripts pueden tomar cualquier número de parámetros, ser read-only o read-write, y pueden ser type subscripts (static subscript).

La memoria: cómo las propiedades definen el layout

Las stored properties de un tipo definen su memory layout:

struct UserProfile {
let id: Int // 8 bytes
var name: String // 16 bytes (the String struct footprint; its backing character buffer is heap-allocated for larger strings)
var age: Int // 8 bytes
var isActive: Bool // 1 byte
} // + 7 bytes de padding para llegar al stride
MemoryLayout<UserProfile>.size // 33 bytes (8 + 16 + 8 + 1)
MemoryLayout<UserProfile>.stride // 40 bytes (con alignment padding)
MemoryLayout<UserProfile>.alignment // 8 bytes

Layout en memoria de UserProfile: los campos stored id (8 bytes), name (16 bytes), age (8 bytes) e isActive (1 byte) ocupan bloques etiquetados seguidos de 7 bytes de padding de alineación, alcanzando size 33 y stride 40 con alignment 8, mientras las computed properties quedan fuera del layout como flechas de función de cero bytes

Las computed properties no aparecen en el layout. No importa cuántas computed properties tengas — el tamaño del tipo no cambia:

struct Circle {
var radius: Double // 8 bytes — la ÚNICA propiedad que ocupa espacio
var diameter: Double { radius * 2 } // 0 bytes
var circumference: Double { .pi * diameter } // 0 bytes
var area: Double { .pi * radius * radius } // 0 bytes
}
MemoryLayout<Circle>.size // 8 bytes — solo radius

Diseña tus tipos pensando en qué necesita ser stored (dato real que cambia y se necesita persistir) y qué puede ser computed (derivado de otros datos). Menos stored properties = menos memoria por instancia.

Recapitulación

  • Stored properties — dato real, ocupan bytes en el memory layout
  • Computed properties — funciones disfrazadas, zero storage, pueden ser inline
  • Lazy properties — se alocan en demanda, útiles para objetos costosos
  • Property observers — willSet/didSet, no agregan storage, SE-0268 optimiza oldValue
  • Type propertiesstatic, una copia compartida, inicializada exactamente una vez incluso entre hilos (vía swift_once)
  • Property wrappers — lógica reutilizable con @, base de SwiftUI
  • Instance methodsself implícito, mutating para value types
  • Type methodsstatic func / class func, se llaman sobre el tipo
  • Subscripts — acceso con [], como computed properties para colecciones
  • Memory layout — solo stored properties definen el tamaño; computed = free

Lo que viene

En el próximo artículo exploramos Herencia, Inicialización y Deinicialización — la trinidad que define el ciclo de vida de las classes. Veremos designated vs convenience initializers, two-phase initialization, failable init, y deinit — el último acto antes de que ARC libere la memoria.

Nos vemos la próxima semana.

Las propiedades son el ADN de tus tipos. Stored properties definen qué sabe tu tipo — y cuánta memoria consume. Computed properties definen qué puede derivar — sin costo. Diseña tus tipos con esa distinción en mente.

Referencias

Relacionados