본문 바로가기
문법관련/Swift

타입 캐스팅 (꼼꼼한 재은 씨의 Swift : 문법편)

by print_soo 2022. 4. 9.

부모 클래스로부터 상속된 자식 클래스는 자기 자신의 타입이기도 하면서, 동시에 부모 클래스의 타입이기도 하다. 이는 부모 클래스의 특성들을 물려받았기 때문으로, 메소드를 오버라이딩했더라도 컴파일러가 클래스의 형태에서 중요하게 여기는 메소드의 이름, 매개변수의 타입 그리고 반환타입이 바뀌지 않는 한 클래스 형식이 달라진 것은 아니다. 

 

class Vehicle {
    var currentSpeed = 0.0
    
    
    
    func accelerate() {
        self.currentSpeed += 1
    }
    
}

class Car: Vehicle {
    var gear: Int {
        return Int(self.currentSpeed / 20) + 1
    }

    func wiper(){
        // 창을 닦는다.
    }

}

let trans: Vehicle = Car()

let car: Car = Vehicle() // Error!!!

 

 

위에서 상수로 선언된 trans는 Car의 인스턴스를 할당받지만 타입은 Vehicle로도 가능하다. 그 이유는 Car 클래스는 Vehicle 클래스를 상속받은 자식 클래스이며, 따라서 Vehicle 클래스에 정의된 모든 프로퍼티와 메소드를 물려받았기 때문이다.

 

 

하지만 아래의 car 상수에 Vehicle의 인스턴스를 할당하고 타입을 Car로 하면 에러가 뜬다. 그 이유는 Vehicle 클래스가 Car 타입이 되기 위해 가져야할 요소들 중에서 gear 프로퍼티나 wiper() 메소드를 가지고 있지 않기 때문이다. 

 

Car 클래스와 vehicle 클래스의 포함관계

 

 

상속을 거듭할 수록 하위 클래스는 상위 클래스보다 점차 구체화되어가며, 상대적으로 상위 클래스는 하위 클래스보다 추상화되어 간다. 

그렇기 때문에 추상화된 상위 객체는 구체화된 하위 객체의 타입이 가져야 하는 조건을 만족시키기에는 부족한 부분이 많다.

 

 

즉, 하위 객체상위 객체 타입의 상수(변수)에 할당이 가능하지만 상위 객체하위 객체 타입의 상수(변수)에 할당이 불가능하다.

 

 

따라서, 상위 클래스 타입으로 선언하면 할수록 사용할 수 있는 메소드와 프로퍼티, 초기화 구문의 범위는 줄어들지만, 변수(상수)에 할당할 수 있는 객체의 종류는 늘어난다. 

 

쉽게 그림과 코드를 함께 설명해보겠다. 


class Vehicle {
    var currentSpeed = 0.0
    
    func accelerate() {
        self.currentSpeed += 1
    }
    
}

class Car: Vehicle {
    var gear: Int {
        return Int(self.currentSpeed / 20) + 1
    }

    func wiper(){
        // 창을 닦는다.
    }

}

class SUV: Car {
    var fourWheel = false
}

 

 

 

위와 같은 클래스들이 작성되어있다고 가정하고 아래의 코드를 살펴보자.

 

 

 

 

1. Vehicle 타입의 SUV 인스턴스가 할당된 상수 someCar1

 

let someCar1: Vehicle = SUV()

 

someCar1은 SUV인스턴스를 할당했지만 타입은 Vehicle 이기 때문에 Vehicle 내부에 있는 프로퍼티와 메서드만 사용가능! 

 

 

2. Car 타입의 SUV 인스턴스가 할당된 상수 someCar2

 

let someCar2: Car = SUV()

 

someCar2은 SUV인스턴스를 할당했지만 타입은 Car 이기 때문에 Car  내부에 있는 프로퍼티와 메서드만 사용가능! 

 

3. SUV 타입의 SUV 인스턴스가 할당된 상수 someCar3

 

let someCar3: SUV = SUV()

 

someCar3은 SUV인스턴스를 할당하고 타입 또한 SUV이기 때문에 모든 프로퍼티와 메서드만 사용가능! 


 

 

 

그렇다면 사용할 수 있는 메소드와 프로퍼티의 범위가 줄어드는 것을 감수하면서까지 상위 타입으로 선언해서 사용하는 이유는 무엇일까?

 

 

 

1. 함수나 메소드의 인자값을 정의할 때 하위 클래스 타입으로 선언하는 것보다 상위 클래스타입으로 선언하면 인자값으로 사용할 수 있는 객체의 범위가 훨씬 늘어나기 때문이다.

 

func move(param: SUV) { 
    param.accelerate()
   
}

func move1(param: Vehicle) {
    param.accelerate()
}

 

move 함수의 매개변수 타입은 SUV이고 move1 함수의 매개변수 타입은 Vehicle이다. 따라서 move 함수는 SUV 클래스의 인스턴스 또는 SUV 클래스를 상속받은 하위 클래스의 인스턴스만 인자값으로 받을 수 있다. 하지만 move1은 Vehicle 클래스나 이를 상속 받은 모든 클래스의 인스턴스를 인지값으로 사용할 수 있게 된다. 

 

 

 

2. 1번과 마찬가지로 배열이나 딕셔너리에서도  사용할 수 있는 인자값의 범위가 넓어진다. 

var list1 = [SUV]()
list1.append(SUV())
list1.append(Vehicle()) // error! - No exact matches in call to instance method 'append'

var list2 = [Vehicle]()
list2.append(Vehicle())
list2.append(Car())
list2.append(SUV())

 

이처럼 하위 클래스 타입 대신 상위 클래스를 타입으로 선언하여 사용하면 주어진 조건을 만족하면서도 훨씬 다양한 객체를 활용할 수 있다.

 

 

 

[타입 비교 연산 - is]

Swift에서는 타입 비교 연산자로 is 를 지원한다. 이 연산자는 할당된 값을 비교하는 것이 아니라 타입이 일치하는지 여부를 비교하고 그 결과를 Bool 형태로 돌려준다. 이 연산자를 사용할 때는 왼쪽이 인스턴스 또는 변수나 상수, 오른쪽은 비교대상 타입을 두는 것이 일반적이다.

 

인스턴스(또는 변수, 상수) is 비교대상 타입

 

이 연산자는 다음과 같은 연산 법칙을 따른다. 

 

1. 연산자 왼쪽 인스턴스의 타입이 연산자 오른쪽 비교대상 타입과 일치할 경우 - true
2. 연산자 왼쪽 인스턴스의 타입이 연산자 오른쪽 비교대상 타입의 하위 클래스일 경우 - true
3. 그 외 - false

 

SUV() is SUV // true
SUV() is Car // true
SUV() is Vehicle // true

Car() is Vehicle // true
Car() is SUV // false

위의 코드 처럼 작성하면 된다. 중요한 것은 왼쪽은 인스턴스, 오른쪽은 타입이어야 한다는 것이다. 

 

또한 is 연산자를 사용할 때 왼쪽에 인스턴스가 할당된 변수가 사용될 경우 주의해야한다. 그 이유는 변수가 선언된 타입을 기준으로 비교하는 것이 아니라 변수에 할당된 실제 인스턴스를 기준으로 타입을 비교하기 때문이다.

let myCar: Vehicle = SUV()

if myCar is SUV {
    print("참")
} else {
    print("거짓")
}

// 결과 - 참
----------------------------
let myCar: Vehicle = Vehicle()

if myCar is SUV {
    print("참")
} else {
    print("거짓")
}

// 결과 - 거짓(myCar에 할당된 Vehicle이 SUV의 하위 클래스가 아니기 때문)

 

 

 

 

[타입 캐스팅 연산 - as]

let someCar1: Vehicle = SUV()

위의 구문은 SUV인스턴스를 할당했지만 타입 어노테이션에 의해서 타입은 Vehicle이기 때문에 Vehicle 내부에 선언된 프로퍼티나 메서드를 사용할 수 있다. 그러면 위의 someCar1 상수를 이용해서 SUV  클래스 내부에 있는 프로퍼티나 메서드를 사용하려면 어떻게(타입 캐스팅을 사용하는 이유) 해야할까?

 

그 해답은 이미 Swift에서 마련해놓았다. 해답은 바로 타입 캐스팅 즉, 행변환이다. 다시 말해 특정 타입으로 선언된 값을 다른 타입으로 변환하는 것을 말한다. 

 

일반적으로 타입 캐스팅은 상속 관계에 있는 타입들 사이에서 허용된다. 자세한건 잠시후에

 

타입 캐스팅은 캐스팅 전 타입과 캐스팅 후 타입의 상위 / 하위 관계에 따라 업 캐스팅(Up Casting)과 다운 캐스팅(Down Casting)으로 나뉜다. 

 

<업 캐스팅>

- 하위 클래스 타입상위 클래스 타입으로 변환할 때 
- 캐스팅하기 전 타입이 하위클래스, 캐스팅한 후 타입이 상위 클래스일 때
- 캐스팅한 결과, 캐스팅하기 전 타입보다 추상화될 때
- 일반적으로 캐스팅 과정에서 오류가 발생할 가능성이 없음

 

<다운 캐스팅>

- 상위 클래스 타입하위 클래스 타입으로 변환할 때
- 캐스팅하기 전 타입이 상위 클래스. 캐스팅한 후 타입이 하위 클래스일 때
- 캐스팅한 결과, 캐스팅하기 전 타입보다 구체화될 때
- 캐스팅 과정에서 오류가 발생할 가능성이 있음
- 오류에 대한 처리 방식에 따라 옵셔널 캐스팅(as?)강제 캐스팅(as!)으로 나누어짐

실제로 우리가 다루는 대다수의 캐스팅이 상위 클래스 타입을 하위 클래스 타입으로 변환하는 타운 캐스팅에 해당하며, 부모 클래스 타입을 자식 클래스 타입으로 변환하는 것이므로 오류가 발생할 가능성을 잠재적으로 가지고 있다. 

 

타입 캐스팅을 위한 연산자는 as이다. 캐스팅할 객체 뒤에 연산자를 붙여주고 이이서 변환할 대상 타입을 작성하면 끝이다.

 

업 캐스팅 - as / 다운 캐스팅 - as?(옵셔널 캐스팅) 또는 as!(강제 캐스팅) 을 사용해야 한다. 

 

옵셔널 캐스팅을 사용하면 캐스팅 결과가 성공이더라도 옵셔널 타입으로 변환되지만 강제 캐스팅을 실행하면 성공했을 때는 일반 타입으로 실패했을 때는 런타임 오류가 발생된다. 

 

<업 캐스팅>
객체 as 변환할 타입
<다운 캐스팅>
객체 as? 변환할 타입 (결과는 옵셔널 타입)
객체 as! 변환할 타입 (결과는 일반 타입)

이제 예시를 보면서 이해해보자. 

 

 

 

[업 캐스팅]

let anyCar: Car = SUV()

let anyVehicle = anyCar as Vehicle // 업 캐스팅

anyCar는 SUV 인스턴스를 할당받고 있지만 타입은 Car 이기 때문에 Car 클래스 내부에 있는 멤버만 사용가능하다. 

하지만 anyVehicle은 anyCar를 업 캐스팅하여 Vehicle 타입으로 변경했기 때문에 Vehicle 내부에 있는  멤버만 사용이 가능해졌다. 

(아래의 사진을 참고하면서 이해해보자.)

사용가능한 멤버

[다운 캐스팅]

let anyCar: Car = SUV()

let anySUV = anyCar as? SUV // 다운 캐스팅
if anySUV != nil {
    print("\(anySUV!) 캐스팅을 성공하였습니다.")
}

anyCar 상수에 선언되었던 Car 클래스보다 캐스팅하고자하는 SUV 클래스가 하위이므로 다운 캐스팅에 해당하낟. 따라서 오류 가능성이 있으므로 옵셔널 캐스팅을 위한 as? 연산자를 사용했다. 

 

위 구문은 아래와 같이 한줄로 축약해서 사용할 수도 있다. 

if let anySUV = anyCar as? SUV {
	print("\(anySUV!) 캐스팅을 성공하였습니다.")
}

 

하지만 만약에 다운 캐스팅이 반드시 성공할 것이라는 확신이 있다면 아래와 같이 강제 캐스팅 구문을 사용해도 된다.

let anySUV = anyCar as! SUV

print("\(anySUV!) 캐스팅을 성공하였습니다.")

Foundation 프레임워크를 사용하다 보면 메소드의 반환 타입이 상위 클래스 타입으로 추상화된 경우가 많은데, 추상화된 객체를 반환 받아 우리가 사용해야 할 적합한 형태로 바꾸기 위해 이러한 타입 캐스팅, 그중에서도 다운 캐스팅을 사용해야 한다. 

 

[Any, AnyObject]

타입 캐스팅을 수행할 때 일반적으로 상속 관계에 있는 클래스들끼리만 캐스팅할 수 있다. 하지만 상속 관계에 있지 않은 클래스 간에 타입 캐스팅을 하면 오류가 발생한다. 

 

그렇다면 상속관계에 있지 않은 클래스 간에 타입 캐스팅은 어떻게 해야할까?

 

Any와 AnyObject 타입을 사용하면 된다. Any와 AnyObject타입은 무엇이든 다 받아들일 수 있는 일종의 범용 타입이다. 

그중에서 AnyObject는 클래스의 일종으로 모든 종류의 클래스 타입을 저장할 수 있는 범용 타입 클래스이다. 클래스 중에서 가장 추상화되어 있는 클래스이며 상속 관계가 성립하지는 않지만 가장 상위 클래스라고 할 수 있다.  따라서 모든 클래스의 인스턴스는 AnyObject 클래스 타입으로 선언된 변수나 상수를 할당할 수 있다. 

 

1. 인스턴스 할당

var allCar: AnyObject = car()
allCar = SUV()

2. 함수 인자값

func move(_ param: AnyObject) -> AnyObject {
	return param
}
move(Car())
move(Vehicle())

3. 배열이나 딕셔너리, 집합

var list = [AnyObject]()

list.append(Vehicle())
list.append(Car())
list.append(SUV())

AnyObject 타입으로 선언된 값은 타입 캐스팅(다운 캐스팅)을 통해서 구체적인 타입으로 변환할 수 있다. 하지만 실제로 저장된 인스턴스 타입과 관계없는 타입으로 캐스팅하면 오류가 발생하므로 주의해야한다. 

 

결론적으로 AnyObject 라는 것은 "어쨋든 클래스이기만 하면 된다" 라는 의미로 해석할 수 있다. 

 

Any 객체는 AnyObject와 다르게 클래스 뿐만 아니라 자료형, 구조체, 열거형, 함수등 모든 타입을 허용하는 특성이 있다. 즉, 어떤 변수의 타입이 Any로 선언되었다면 이 변수에는 종류에 상관없이 모든 타입의 객체를 저장할 수 있는 것이다. 

var value: Any = "이건 애니"

value = 3
value = false
value = [1, 2, 3]
value = {
    print("함수")
}

 

그러면 이런 생각이 들 것이다. 이렇게 좋은 Any 타입이 있다면 다른 타입은 다 없애고 Any 타입만 쓰면 되는거 아닌가? 

 

하지만 실제로 사용해보면 불편한 점이 많다.  Any타입은 매우 극단적으로 추상화된 타입이라서 Any타입에 할당된 객체가 사용할 수 있는 프로퍼티나 메소드는 아예 제공되지 않는다. 즉, 모든 값을 제한 없이 할당 받을 수 있지만, 그 값을 이용해서 할 수 있는 것은 거의 없어지는 셈이다. 

 

또한 Any 타입은 정적인 타입을 동적인 타읍로 바꾸어버린다. 즉, 실제로 값이 할당되는 시점에 타입이 정해진다. 따라서 실행하기 전에는 값의 타입을 알기 어려우므로 컴파일러가 오류를 잡아내지 못한다. 모든 오류가 런타임 오류로 발생하는 결과를 낳게 된다.