타입 강제 변환(type coercion)은 객체의 타입을 값과 함께 다른 타입으로 바꾸는 것을 말합니다. 예를 들어 #to_s로 Integer를 String으로 바꾸거나, #to_i로 Float을 Integer로 바꾸는 경우입니다. 한편 일부 객체가 구현하는 #to_str과 #to_int 메서드도 언뜻 보면 같은 작업을 하는 것처럼 보이지만, 실제로는 중요한 차이점이 있습니다.
이번 글에서는 루비에서 타입을 명시적으로 캐스팅하는 방법과 암시적으로 강제 변환하는 방법을 깊이 있게 살펴보겠습니다. 두 방식의 차이점은 물론, 각각이 실제로 어떻게 활용되는지도 함께 다룹니다.
먼저 명시적 캐스팅 헬퍼를 통해 루비에서 값을 다른 타입으로 변환하는 일반적인 방법부터 알아보겠습니다.
명시적 캐스팅 헬퍼 (Explicit Casting Helpers)
가장 널리 쓰이는 캐스팅 헬퍼는 #to_s, #to_i, #to_a, #to_h입니다. 이들은 명시적(explicit) 캐스팅 메서드로, 한 타입의 값을 다른 타입으로 손쉽게 변환해 줍니다.
명시적 헬퍼에는 하나의 분명한 약속이 있습니다. 객체에 #to_s를 호출하면, 해당 객체가 문자열로 깔끔하게 변환되지 않더라도 항상 문자열을 반환합니다. 마치 코미디 배우인 마이클 키턴을 배트맨 역에 캐스팅하는 것과 같습니다. 배역에 딱 맞는 배우는 아니어도, 결과물로 배트맨은 반드시 얻게 되는 셈입니다.
루비는 표준 라이브러리의 거의 모든 기본 객체에 이러한 헬퍼 메서드를 제공합니다.
:foo.to_s # => "foo"
10.0.to_i # => 10
"10".to_i # => 10특히 #to_s를 포함한 이 메서드들은 루비의 대부분 기본 타입에 구현되어 있습니다. 캐스팅은 거의 항상 값을 반환하지만, 그 결과가 우리가 기대한 것과 다를 수 있다는 점에 주의해야 합니다.
"foo10".to_i # => 0
[1, 2, 3].to_s # => "[1, 2, 3]"
{ :foo => :bar }.to_s # => "{:foo=>:bar}"
{ :foo => :bar }.to_a # => [[:foo, :bar]]
Object.to_s # => "Object"
Object.new.to_s # => "#<Object:0x00007f8e6d053a90>"#to_s, #to_i, #to_a, #to_h 헬퍼를 호출하면 어떤 값이든 지정한 타입으로 강제 변환됩니다. 값 자체에 무슨 일이 일어나든 상관없이, 변환된 타입의 표현(representation)을 무조건 반환합니다.
암시적 강제 변환 메서드 (Implicit Coercion Methods)
반면, 캐스팅 대상 타입처럼 동작하지 않는 값에 캐스팅 메서드를 호출하면 오류가 발생하거나 데이터가 손실될 수 있습니다. 루비는 이를 보완하기 위해 객체가 해당 타입처럼 동작할 때만 값을 반환하는 암시적(implicit) 강제 변환 메서드도 제공합니다. 이 방식을 사용하면 값이 원하는 타입처럼 실제로 동작한다는 것을 확신할 수 있습니다. 암시적 강제 변환 메서드에는 #to_str, #to_int, #to_ary, #to_hash가 있습니다.
암시적 강제 변환은 배역 캐스팅에 비유하자면, 스파크 외의 역할에 레너드 니모이를 캐스팅하는 것과 같습니다. 캐릭터가 스파크와 충분히 비슷하다면 연기가 잘 어울리지만, 그렇지 않으면 성립하지 않습니다. #to_str 헬퍼는 문자열로 변환을 시도하지만, 객체가 이 메서드를 구현하지 않아 암시적으로 변환될 수 없는 경우 NoMethodError를 발생시킵니다.
10.to_int # => 10
10.0.to_int # => 10
require "bigdecimal"
BigDecimal.new("10.0000123").to_int # => 10
# Unsuccessful coercions
"10".to_int # => NoMethodError
"foo10".to_int # => NoMethodError
[1, 2, 3].to_str # => NoMethodError
{ :foo => :bar }.to_str # => NoMethodError
{ :foo => :bar }.to_ary # => NoMethodError
Object.to_str # => NoMethodError
Object.new.to_str # => NoMethodError결과를 보면 루비가 어떤 값을 요청된 타입으로 변환할 수 있고 없는지에 대해 훨씬 엄격해진 것을 알 수 있습니다. 변환이 불가능한 경우 해당 객체에는 #to_* 메서드 자체가 구현되어 있지 않으므로, 호출 시 NoMethodError가 발생합니다.
암시적 강제 변환(예: #to_str)을 사용한다는 것은, 원래 타입이 String처럼 동작하는 경우에 한해서만 String 객체를 반환해 달라고 요청하는 것입니다. 이러한 이유로 루비 표준 라이브러리에서 #to_str은 String 클래스에만 구현되어 있습니다.
루비가 암시적 강제 변환을 활용하는 방식
강제 변환 시 더 정확한 조건을 요구할 수 있다는 점 외에, 암시적 강제 변환은 또 어떤 용도로 유용할까요? 흥미롭게도 루비 자체도 여러 시나리오에서 내부적으로 암시적 강제 변환을 사용합니다. 대표적인 예가 + 연산자로 객체를 결합하는 경우입니다.
name = "world!"
"Hello " + name # => "Hello world!"
# Without #to_str
class Name
def initialize(name)
@name = name
end
end
"Hello " + Name.new("world!") # => TypeError: no implicit conversion of Name into String위 예제에서 루비는 Name 타입을 String으로 암시적 변환할 수 없기 때문에 TypeError를 발생시킵니다.
그러나 클래스에 #to_str을 구현하면, 루비는 Name 타입을 어떻게 변환해야 하는지 인식합니다.
# With #to_str
class Name
def to_str
@name
end
end
"Hello " + Name.new("world!") # => "Hello world!"동일한 원리가 Array와 #to_ary에도 적용됩니다.
class Options
def initialize
@internal = []
end
def <<(value)
@internal << value
end
end
options = Options.new
options << :foo
[:some_prefix] + options # => TypeError: no implicit conversion of Options into Array
class Options
def to_ary
@internal
end
end
[:some_prefix] + options # => [:some_prefix, :foo]그런데 #to_ary는 이보다 더 다양한 곳에서 활용됩니다. 예를 들어 배열을 여러 개의 개별 변수로 분해(destructure)하는 데 사용할 수 있습니다.
options = Options.new
options << :first
options << :second
options << :third
first, second, third = options
first # => :first
second # => :second
third # => :third블록 매개변수로 객체를 전달할 때도 자동 변환이 일어납니다.
[options].each do |(first, second)|
first # => :first
second # => :second
end그 밖에도 암시적 강제 변환 메서드가 쓰이는 시나리오가 여럿 있습니다. 예를 들어 ** 연산자와 함께 #to_hash를 사용하면, 값을 parse_options 메서드에 넘기기 전에 #to_hash로 해시로 변환합니다.
class Options
def to_hash
# Create a hash from the Options Array
Hash[*@internal]
end
end
def parse_options(opts)
opts
end
options = Options.new
options << :key
options << :value
parse_options(**options) # => {:key=>:value}타입 강제하기 (Enforcing Types)
루비는 값의 타입을 미리 알 수 없고 올바른 타입을 반드시 받고 싶은 상황을 위해 더 견고한 강제 변환 메서드도 제공합니다. 모든 기본 타입마다 하나씩 존재합니다(String(...), Integer(...), Float(...), Array(...), Hash(...) 등).
String(self) # => "main"
String(self.class) # => "Object"
String(123456) # => "123456"
String(nil) # => ""
Integer(123.999) # => 123
Integer("0x1b") # => 27
Integer(Time.new) # => 1204973019
Integer(nil) # => TypeError: can't convert nil into IntegerString(...) 메서드는 먼저 값에 #to_str을 호출해 보고, 실패하면 이어서 #to_s 메서드를 호출합니다. 모든 객체가 #to_str 메서드를 정의하는 것은 아니기 때문에, 암시적 강제 변환(#to_str)과 명시적 캐스팅(#to_s)을 순차적으로 확인하면 문자열 변환이 성공하고 원하는 값을 얻을 확률이 높아집니다. 암시적 강제 변환을 우선 시도하기 때문에, "#<Object:0x00007f8e6d053a90>" 같은 디버깅용 출력이 아니라 원래 값 그대로를 유지하면서 타입만 변환된 결과를 얻을 가능성이 커집니다.
class MyString
def initialize(value)
@value = value
end
def to_str
@value
end
end
s = MyString.new("hello world")
s.to_s # => "#<MyString:0x...>"
s.to_str # => "hello world"
String(s) # => "hello world"단, 암시적 캐스팅 메서드는 변환 대상 타입처럼 동작하는 객체에 한해서만 구현해야 합니다. 예컨대 직접 만든 String 계열 클래스에 #to_str을 구현하는 식입니다.
암시적 강제 변환을 먼저 시도하는 것 외에도, String(...) 헬퍼는 반환된 값의 타입까지 검사합니다. #to_str은 어디까지나 일반 메서드이므로 문자열이 아닌 값을 반환할 수도 있습니다. 요청된 타입의 값을 확실히 보장하기 위해 String(...)은 타입이 일치하지 않으면 TypeError를 발생시킵니다.
class MyString
def to_str
nil
end
end
s = MyString.new("hello world")
s.to_s # => "#<MyString:0x...>"
s.to_str # => nil
String(s) # => "#<MyString:0x...>"위 예제에서 루비는 #to_str이 String 타입이 아닌 nil을 반환했기 때문에 그 결과를 무시하고, 대신 #to_s의 결과로 폴백(fallback)하는 것을 볼 수 있습니다.
만약 #to_s마저 nil을 반환해 올바른 타입이 아니라면, String(...)은 TypeError를 발생시킵니다.
class MyString
def to_str
nil
end
def to_s
nil
end
end
s = MyString.new("hello world")
s.to_s # => nil
s.to_str # => nil
String(s) # => TypeError: can't convert MyString to String (MyString#to_s gives NilClass)이처럼 캐스팅 헬퍼 메서드(String(...), Integer(...) 등)는 타입 강제 변환 면에서 더 신뢰할 수 있지만, 주어진 값에 대해 더 많은 검사를 수행해야 하기 때문에 일반적으로 실행 속도가 다소 느리다는 점도 기억해 두세요.
마무리
객체가 올바른 타입의 데이터를 다루고 있는지 확인해야 할 때, 타입 강제 변환은 매우 유용한 도구입니다. 이번 글에서는 #to_s, #to_i, #to_a, #to_h 같은 명시적 캐스팅 헬퍼에 대한 지식을 정리하고, #to_str, #to_int, #to_ary, #to_hash 같은 암시적 헬퍼가 어떤 상황에서 빛을 발하는지, 그리고 루비 자체가 이들을 어떻게 활용하는지 살펴보았습니다.
이번 타입 강제 변환 개요가 도움이 되었고, 배역 캐스팅 비유도 재미있게 읽으셨기를 바랍니다. 다루어 주었으면 하는 주제가 있다면 언제든지 알려주세요. 궁금한 점이나 의견이 있다면 @AppSignal로 편하게 문의해 주세요.