[Swift] Swift 기초(2)

someng·2022년 7월 26일
0

iOS

목록 보기
2/33

1️⃣ 함수와 클로저

📌 함수

함수는 func 키워드를 사용해서 정의하고, -> 를 사용해서 함수의 반환 타입을 지정합니다.

func hello(name: String, time: Int) -> String {
  var string = ""
  for _ in 0..<time {
    string += "\(name)님 안녕하세요!\n"
  }
  return string
}

Swift에서는 독특하게 함수를 호출할 때 파라미터 이름을 함께 써주어야 합니다.

hello(name: "전수열", time: 3)

파라미터 이름을 _로 정의하면 함수를 호출할 때 파라미터 이름생략할 수 있게 됩니다.

func hello(_ name: String, time: Int) {
  // ...
}

hello("전수열", time: 3) // 'name:' 이 생략되었습니다.

...을 사용하면 개수가 정해지지 않은 파라미터(Variadic Parameters)를 받을 수 있습니다.

func sum(_ numbers: Int...) -> Int {
  var sum = 0
  for number in numbers {
    sum += number
  }
  return sum
}

sum(1, 2)
sum(3, 4, 5)

📌 클로저 (Closure)

: 중괄호( {} )로 감싸진 '실행 가능한 코드 블럭'

func helloGenerator(message: String) -> (String, String) -> String {
  return { (firstName: String, lastName: String) -> String in
    return lastName + firstName + message
  }
}

함수와는 다르게 함수 이름 정의가 따로 존재하지 않습니다. 하지만 파라미터를 받을 수 있고, 반환 값이 존재할 수 있다는 점에서 함수와 동일합니다. 함수와 조금 다른 점은 in 키워드를 사용해서 파라미터, 반환 타입 영역과 실제 클로저의 코드를 분리하고 있습니다.

Swift 컴파일러의 타입 추론 덕분에, helloGenerator() 함수에서 반환하는 타입을 가지고 클로저에서 어떤 파라미터를 받고 어떤 타입을 반환하는지를 알 수 있습니다. 과감하게 생략해버리죠.

func helloGenerator(message: String) -> (String, String) -> String {
  return { firstName, lastName in
    return lastName + firstName + message
  }
}

타입 추론 덕분에 첫 번째 파라미터가 문자열이고, 두 번째 파라미터도 문자열이라는 것을 알 수 있습니다. 첫 번째 파라미터는 $0, 두 번째 파라미터는 $1로 바꿔서 쓸 수 있습니다.

func helloGenerator(message: String) -> (String, String) -> String {
  return {
    return $1 + $0 + message
  }
}

클로저 내부의 코드가 한 줄이라면, return까지도 생략해버릴 수 있답니다!

func helloGenerator(message: String) -> (String, String) -> String {
  return { $1 + $0 + message }
}

클로저는 변수처럼 정의할 수 있습니다.

let hello: (String, String) -> String = { $1 + $0 + "님 안녕하세요!"}
hello("수열", "전")

물론 옵셔널로도 정의할 수 있습니다. 옵셔널 체이닝도 가능하고요.

let hello: ((String, String) -> String)?
hello?("수열", "전")

클로저를 변수로 정의하고 함수에서 반환할 수도 있는 것처럼, 파라미터로도 받을 수 있습니다.

func manipulate(number: Int, using block: Int -> Int) -> Int {
  return block(number)
}

manipulate(number: 10, using: { (number: Int) -> Int in
  return number * 2
})

아까 했던 것처럼, 생략할 수도 있습니다.

manipulate(number: 10, using: {
  $0 * 2
})

함수가 클로저 하나만을 파라미터로 받는다면, 괄호를 아예 쓰지 않아도 됩니다.

let numbers = [1, 3, 2, 6, 7, 5, 8, 4]

let sortedNumbers = numbers.sort { $0 < $1 }
print(sortedNumbers) // [1, 2, 3, 4, 5, 6, 7, 8]

let evens = numbers.filter { $0 % 2 == 0 }
print(evens) // [2, 6, 8, 4]

✔️ 클로저 활용하기

  • map()
    : 파라미터로 받은 클로저모든 요소에 실행하고, 그 결과를 반환합니다.
    예를 들어, 정수 배열의 모든 요소들에 2를 더한 값으로 이루어진 배열을 만들고 싶다면, 이렇게 작성할 수 있습니다.
let arr1 = [1, 3, 6, 2, 7, 9]
let arr2 = arr1.map { $0 * 2 } // [2, 6, 12, 4, 14, 18]
  • reduce()
    : 초깃값이 주어지고, 초깃값과 첫 번째 요소의 클로저 실행 결과, 그리고 그 결과와 두 번째 요소의 클로저 실행 결과, 그리고 그 결과와 세 번째 요소의 클로저 실행 결과, ... 끝까지 실행한 후의 값을 반환합니다.
    바로 위에서 정의한 arr1의 모든 요소의 합을 구하고 싶다면, 아래와 같이 작성할 수 있습니다.
arr1.reduce(0) { $0 + $1 } // 28

2️⃣ 클래스와 구조체

클래스는 class로 정의하고, 구조체는 struct로 정의합니다.

class Dog {
  var name: String?
  var age: Int?

  func simpleDescription() -> String {
    if let name = self.name {
      return "🐶 \(name)"
    } else {
      return "🐶 No name"
    }
  }
}

struct Coffee {
  var name: String?
  var size: String?

  func simpleDescription() -> String {
    if let name = self.name {
      return "☕️ \(name)"
    } else {
      return "☕️ No name"
    }
  }
}

var myDog = Dog()
myDog.name = "찡코"
myDog.age = 3
print(myDog.simpleDescription()) // 🐶 찡코

var myCoffee = Coffee()
myCoffee.name = "아메리카노"
myCoffee.size = "Venti"
print(myCoffee.simpleDescription()) // ☕️ 아메리카노

클래스는 상속이 가능합니다. 구조체는 불가능합니다.

class Animal {
  let numberOfLegs = 4
}

class Dog: Animal {
  var name: String?
  var age: Int?
}

var myDog = Dog()
print(myDog.numberOfLegs) // Animal 클래스로부터 상속받은 값 (4)

클래스는 참조(Reference)하고, 구조체는 복사(Copy)합니다.

var dog1 = Dog()  // dog1은 새로 만들어진 Dog()를 참조합니다.
var dog2 = dog1   // dog2는 dog1이 참조하는 Dog()를 똑같이 참조합니다.
dog1.name = "찡코" // dog1의 이름을 바꾸면 Dog()의 이름이 바뀌기 때문에,
print(dog2.name)  // dog2의 이름을 가져와도 바뀐 이름("찡코")이 출력됩니다.

var coffee1 = Coffee()   // coffee1은 새로 만들어진 Coffee() 그 자체입니다.
var coffee2 = coffee1    // coffee2는 coffee1을 복사한 값 자체입니다.
coffee1.name = "아메리카노" // coffee1의 이름을 바꿔도
coffee2.name             // coffee2는 완전히 별개이기 때문에 이름이 바뀌지 않습니다. (nil)

📌 생성자 (Initializer)

클래스와 구조체 모두 생성자를 가지고 있습니다. 생성자에서는 속성의 초깃값을 지정할 수 있습니다.

class Dog {
  var name: String?
  var age: Int?

  init() {
    self.age = 0
  }
}

struct Coffee {
  var name: String?
  var size: String?

  init() {
    self.size = "Tall"
  }
}

만약 속성이 옵셔널이 아니라면 항상 초깃값을 가져야 합니다. 만약 옵셔널이 아닌 속성이 초깃값을 가지고 있지 않으면 컴파일 에러가 발생합니다.

class Dog {
  var name: String?
  var age: Int // 컴파일 에러!
}

1) 속성을 정의할 때 초깃값을 지정해 주는 방법

class Dog {
  var name: String?
  var age: Int = 0 // 속성을 정의할 때 초깃값 지정
}

2) 생성자에서 초깃값을 지정해주는 방법

class Dog {
  var name: String?
  var age: Int

  init() {
    self.age = 0 // 생성자에서 초깃값 지정
  }
}

생성자도 함수와 마찬가지로 파라미터를 받을 수 있습니다.

📌 속성 (Properties)

속성은 크게 두 가지로 나뉩니다. 값을 가지는 속성(Stored Property)과 계산되는 속성(Computed Property) 인데요. 쉽게 말하면 속성이 값 자체를 가지고 있는지, 혹은 어떠한 연산을 수행한 뒤 그 결과를 반환하는지의 차이입니다.

Computed Property는 get, set을 사용해서 정의할 수 있습니다. set에서는 새로 설정될 값을 newValue라는 예약어를 통해 접근할 수 있습니다.

struct Hex {
  var decimal: Int?
  var hexString: String? {
    get {
      if let decimal = self.decimal {
        return String(decimal, radix: 16)
      } else {
        return nil
      }
    }
    set {
      if let newValue = newValue {
        self.decimal = Int(newValue, radix: 16)
      } else {
        self.decimal = nil
      }
    }
  }
}

var hex = Hex()
hex.decimal = 10
hex.hexString // "a"

hex.hexString = "b"
hex.decimal // 11

위 코드에서 hexString은 실제 값을 가지고 있지는 않지만, decimal로부터 값을 받아와 16진수 문자열로 만들어서 반환합니다. decimal은 Stored Property, hexString은 Computed Property입니다.

참고로, get만 정의할 경우에는 get 키워드를 생략할 수 있습니다. 이런 속성을 읽기 전용(Read Only)이라고 합니다.

3️⃣ 튜플

: 어떠한 값들의 묶음.
배열과 비슷하다고 볼 수 있지만, 배열과는 다르게 길이가 고정되어 있다.
값에 접근할 때에도 [] 대신 .을 사용한다.

var coffeeInfo = ("아메리카노", 5100)
coffeeInfo.0 // 아메리카노
coffeeInfo.1 // 5100
coffeeInfo.1 = 5100

이 튜플의 파라미터에 이름을 붙일 수도 있다.

var namedCoffeeInfo = (coffee: "아메리카노", price: 5100)
namedCoffeeInfo.coffee // 아메리카노
namedCoffeeInfo.price // 5100
namedCoffeeInfo.price = 5100

📌 튜플의 타입 어노테이션

var coffeeInfo: (String, Int)
var namedCoffeeInfo: (coffee: String, price: Int)

튜플이 가진 값을 가지고 변수에 값을 지정할 때, 무시하고 싶은 값이 있다면 _ 키워드를 사용해서 할 수 있습니다. 아래 코드에서는 "라떼"라는 첫 번째 값을 무시합니다.

let (_, latteSize, lattePrice) = ("라떼", "Venti", 5600)
latteSize // Venti
lattePrice // 5600

4️⃣ Enum

: 열거라는 뜻을 가진 Enumeration에서 따온 용어

1월부터 12월까지를 enum으로 한 번 정의해보자.

enum Month: Int {
  case january = 1
  case february
  case march
  case april
  case may
  case june
  case july
  case august
  case september
  case october
  case november
  case december

  func simpleDescription() -> String {
    switch self {
    case .january:
      return "1월"
    case .february:
      return "2월"
    case .march:
      return "3월"
    case .april:
      return "4월"
    case .may:
      return "5월"
    case .june:
      return "6월"
    case .july:
      return "7월"
    case .august:
      return "8월"
    case .september:
      return "9월"
    case .october:
      return "10월"
    case .november:
      return "11월"
    case .december:
      return "12월"
    }
  }
}

let december = Month.december
print(december.simpleDescription()) // 12월
print(december.rawValue)            // 12

일반적으로 Enum은 Int만을 원시값으로 가질 수 있다고 생각합니다. 다른 프로그래밍 언어에서는 모두 그렇거든요. 하지만, Swift의 Enum은 조금 독특합니다. 아래 예시는 String을 원시값으로 가지는 Enum입니다.

enum IssueState: String {
  case open = "open"
  case closed = "closed"
}

Enum은 원시값을 가지지 않을 수도 있습니다. 원시값을 가져야 할 필요가 없다면 굳이 만들지 않아도 돼요.

enum Spoon {
  case dirt
  case bronze
  case silver
  case gold

  func simpleDescription() -> String {
    switch self {
    case .dirt:
      return "흙수저"
    case .bronze:
      return "동수저"
    case .silver:
      return "은수저"
    case .gold:
      return "금수저"
    }
  }
}

🕊 Enum의 이름 생략

Enum을 예측할 수 있다면 Enum의 이름을 생략할 수 있습니다. 코드가 굉장히 간결해지겠죠?

let spoon: Spoon = .gold // 변수에 타입 어노테이션이 있기 때문에 생략 가능

func doSomething(with spoon: Spoon) {
  // ...
}
doSomething(with: .silver) // 함수 정의에 타입 어노테이션이 있기 때문에 생략 가능

📌 연관 값 (Associated Values) 을 가지는 Enum

Enum은 연관 값(Associated Values)을 가질 수 있습니다. 아래 예시는 어떤 API에 대한 에러를 정의한 것인데요. invalidParameter 케이스는 필드 이름과 메시지를 가지도록 정의되었습니다.

enum NetworkError {
  case invalidParameter(String, String)
  case timeout
}

let error: NetworkError = .invalidParameter("email", "이메일 형식이 올바르지 않습니다.")

이 값을 꺼내올 수 있는 방법으로는 if-case 또는 switch를 활용하는 방법이 있습니다.

if case .invalidParameter(let field, let message) = error {
  print(field) // email
  print(message) // 이메일 형식이 올바르지 않습니다.
}

switch error {
case .invalidParameter(let field, let message):
  print(field) // email
  print(message) // 이메일 형식이 올바르지 않습니다.

default:
  break
}

5️⃣ 프로토콜(Protocol)

: 인터페이스
최소한으로 가져야 할 속성이나 메서드를 정의한다. 구현은 하지 않는다!

/// 전송가능한 인터페이스를 정의합니다.
protocol Sendable {
  var from: String? { get }
  var to: String { get }

  func send()
}

클래스구조체에 프로토콜을 적용(Conform)시킬 수 있습니다. 프로토콜을 적용하면, 프로토콜에서 정의한 속성메서드모두 구현해야 합니다.

struct Mail: Sendable {
  var from: String?
  var to: String

  func send() {
    print("Send a mail from \(self.from) to \(self.to)")
  }
}

📌 Any와 AnyObject

Any: 모든 타입에 대응
AnyObject: 모든 객체(Object)에 대응

let anyNumber: Any = 10
let anyString: Any = "Hi"

let anyInstance: AnyObject = Dog()

AnyAnyObject는 프로토콜입니다.
Swift에서 사용 가능한 모든 타입은 Any를 따르도록 설계되었고,
모든 클래스들에는 AnyObject 프로토콜이 적용되어있습니다.

📌 타입 캐스팅 (Type Casting)

anyNumber에 10을 넣었다고 해서 anyNumberInt는 아닙니다. 'Any 프로토콜을 따르는 어떤 값'이기 때문이죠.

anyNumber + 1 // 컴파일 에러!

이럴 때에는 as를 이용해서 다운 캐스팅(Down Casting)을 해야 합니다. Any는 Int보다 더 큰 범위이기 때문에, 작은 범위로 줄인다고 하여 '다운 캐스팅'입니다.

let number: Int? = anyNumber as? Int

옵셔널이기 때문에, 옵셔널 바인딩 문법도 사용할 수 있습니다. 실제로 이렇게 사용하는 경우가 굉장히 많습니다.

if let number = anyNumber as? Int {
  print(number + 1)
}

📌 Swift 주요 프로토콜

CustomStringConvertible

자기 자신을 표현하는 문자열을 정의합니다. print(), String() 또는 "()"에서 사용될 때의 값입니다.
CustomStringConvertible의 정의는 아래와 같이 생겼습니다.

public protocol CustomStringConvertible {
  /// A textual representation of `self`.
  public var description: String { get }
}

실제로 적용해볼까요?

struct Dog: CustomStringConvertible {
  var name: String
  var description: String {
    return "🐶 \(self.name)"
  }
}

let dog = Dog(name: "찡코")
print(dog) // 🐶 찡코

ExpressibleBy

우리는 지금까지 10은 Int, "Hi"는 String이라고 '당연하게' 인지하고 있었습니다. 하지만, 엄밀히 하자면 10은 원래 Int(10)으로 선언되어야 하고, "Hi"String("Hi")로 선언되어야 합니다. Int와 String 모두 생성자를 가지는 구조체이기 때문이죠.
이렇게, 생성자를 사용하지 않고도 생성할 수 있게 만드는 것을 리터럴(Literal)이라고 합니다. 직역하면 '문자 그대로'라는 뜻이에요.

이 리터럴을 가능하게 해주는 프로토콜이 있답니다. 바로 ExpressibleByXXXLiteral 인데요.
IntExpressibleByIntegerLiteral을, StringExpressibleByStringLiteral을, ArrayExpressibleByArrayLiteral을, DictionaryExpressibleByDictionaryLiteral 프로토콜을 따르고 있습니다. 각 프로토콜은 리터럴 값을 받는 생성자를 정의하고 있어요.

📎 출처

40시간 만에 Swift로 iOS앱 만들기

profile
👩🏻‍💻 iOS Developer

0개의 댓글