고급 연산자
고급 연산자 (Advanced Operators)
커스텀 연산자를 정의하고, 비트 연산을 수행하고, 빌더 문법을 사용하는 방법을 살펴봐요.
doc:BasicOperators에서 다룬 연산자들에 더해, Swift는 더 복잡한 값 조작을 수행하는 여러 고급 연산자를 제공해요. 여기에는 C나 Objective-C를 써 본 사람이라면 익숙할 모든 비트 연산자와 비트 시프트 연산자가 포함돼요.
C의 산술 연산자와 달리, Swift의 산술 연산자는 기본적으로 오버플로가 발생하지 않아요. 오버플로가 일어나려 하면 연산이 중단되고 오류로 보고돼요. 오버플로 동작을 선택하려면 기본적으로 오버플로가 일어나는 Swift의 두 번째 산술 연산자 집합을 쓰면 돼요. 예를 들어 오버플로 덧셈 연산자(&+)가 있죠. 이 오버플로 연산자들은 모두 앰퍼샌드(&)로 시작해요.
여러분이 직접 구조체, 클래스, 열거형을 정의할 때, 이 커스텀 타입들을 위해 표준 Swift 연산자의 나만의 구현을 제공하면 유용할 때가 있어요. Swift는 이런 연산자들의 맞춤 구현을 쉽게 만들 수 있게 해 주고, 여러분이 만드는 각 타입에 대해 그 동작을 정확히 정할 수 있게 해 줘요.
미리 정의된 연산자에만 국한될 필요도 없어요. Swift는 나만의 커스텀 중위(infix), 전위(prefix), 후위(postfix), 대입 연산자를 우선순위와 결합성 값을 직접 정해서 정의할 자유를 줘요. 이 연산자들은 미리 정의된 연산자처럼 코드에서 쓸 수 있고, 심지어 기존 타입을 확장해서 여러분이 정의한 커스텀 연산자를 지원하게 만들 수도 있어요.
본문
비트 연산자 (Bitwise Operators)
*비트 연산자(bitwise operator)*는 데이터 구조 안의 낱개 원시 데이터 비트들을 조작할 수 있게 해 줘요. 그래픽 프로그래밍이나 장치 드라이버 제작 같은 저수준 프로그래밍에서 흔히 쓰여요. 또 외부 소스에서 온 원시 데이터를 다룰 때도 유용한데, 예를 들어 커스텀 프로토콜로 통신하는 데이터를 인코딩하고 디코딩하는 경우가 그렇죠.
Swift는 아래에서 설명하는 것처럼 C에 있는 모든 비트 연산자를 지원해요.
비트 NOT 연산자 (Bitwise NOT Operator)
비트 NOT 연산자(~)는 숫자의 모든 비트를 뒤집어요:
비트 NOT 연산자는 전위 연산자라서, 연산 대상 값 바로 앞에 공백 없이 붙어요:
let initialBits: UInt8 = 0b00001111
let invertedBits = ~initialBits // equals 11110000
UInt8 정수는 8개의 비트를 가지므로 0부터 255 사이의 값을 담을 수 있어요. 이 예시는 UInt8 정수를 이진수 값 00001111로 초기화하는데, 앞쪽 4비트는 0, 뒤쪽 4비트는 1이에요. 이는 십진수 15와 같아요.
그다음 비트 NOT 연산자로 invertedBits라는 새 상수를 만드는데, 이 값은 initialBits와 같되 모든 비트가 뒤집힌 값이에요. 0은 1이 되고 1은 0이 되죠. invertedBits의 값은 11110000이고, 이는 부호 없는 십진수 240과 같아요.
비트 AND 연산자 (Bitwise AND Operator)
비트 AND 연산자(&)는 두 숫자의 비트를 결합해요. 두 입력 숫자의 비트가 둘 다 1인 위치에만 새 숫자의 비트가 1로 설정됩니다.
아래 예시에서 firstSixBits와 lastSixBits의 값은 모두 가운데 4비트가 1이에요. 비트 AND 연산자가 이 둘을 결합해 00111100이라는 숫자를 만드는데, 이는 부호 없는 십진수 60과 같아요:
let firstSixBits: UInt8 = 0b11111100
let lastSixBits: UInt8 = 0b00111111
let middleFourBits = firstSixBits & lastSixBits // equals 00111100
비트 OR 연산자 (Bitwise OR Operator)
비트 OR 연산자(|)는 두 숫자의 비트를 비교해요. 두 입력 숫자 중 어느 한쪽이라도 비트가 1인 위치에 새 숫자의 비트가 1로 설정됩니다.
아래 예시에서 someBits와 moreBits의 값은 서로 다른 위치에 비트가 1로 설정돼 있어요. 비트 OR 연산자가 이 둘을 결합해 11111110이라는 숫자를 만드는데, 이는 부호 없는 십진수 254와 같아요:
let someBits: UInt8 = 0b10110010
let moreBits: UInt8 = 0b01011110
let combinedbits = someBits | moreBits // equals 11111110
비트 XOR 연산자 (Bitwise XOR Operator)
비트 XOR 연산자, 또는 "배타적 OR 연산자"(^)는 두 숫자의 비트를 비교해요. 두 입력 비트가 서로 다른 곳에서는 새 숫자의 비트가 1로 설정되고, 입력 비트가 같으면 0으로 설정됩니다.
아래 예시에서 firstBits와 otherBits의 값은 서로 다른 위치에 비트가 1로 설정돼 있어요. 비트 XOR 연산자는 두 비트 모두 출력 값에서 1로 설정해요. firstBits와 otherBits의 나머지 비트는 모두 일치하므로 출력 값에서 0으로 설정돼요:
let firstBits: UInt8 = 0b00010100
let otherBits: UInt8 = 0b00000101
let outputBits = firstBits ^ otherBits // equals 00010001
비트 좌·우 시프트 연산자 (Bitwise Left and Right Shift Operators)
비트 좌 시프트 연산자(<<)와 비트 우 시프트 연산자(>>)는 아래에서 정의한 규칙에 따라 숫자의 모든 비트를 특정 자리 수만큼 왼쪽이나 오른쪽으로 이동시켜요.
비트 좌·우 시프트는 정수를 2의 배수로 곱하거나 나누는 효과가 있어요. 정수의 비트를 왼쪽으로 한 칸 시프트하면 값이 두 배로 늘고, 오른쪽으로 한 칸 시프트하면 값이 절반으로 줄어요.
부호 없는 정수의 시프트 동작 (Shifting Behavior for Unsigned Integers)
부호 없는 정수의 비트 시프트 동작은 다음과 같아요:
- 기존 비트를 요청한 자리 수만큼 왼쪽이나 오른쪽으로 이동시킵니다.
- 정수의 저장 범위를 벗어나 이동된 비트는 버려집니다.
- 원래 비트가 왼쪽이나 오른쪽으로 이동한 뒤 남는 자리에는 0이 채워집니다.
이 방식을 *논리 시프트(logical shift)*라고 불러요.
아래 그림은 11111111 << 1(즉 11111111을 왼쪽으로 1칸 시프트한 것)과 11111111 >> 1(즉 11111111을 오른쪽으로 1칸 시프트한 것)의 결과를 보여줘요. 초록색 숫자는 시프트된 비트, 회색 숫자는 버려진 비트, 분홍색 0은 새로 채워진 비트예요:
비트 시프트가 Swift 코드에서는 이렇게 보여요:
let shiftBits: UInt8 = 4 // 00000100 in binary
shiftBits << 1 // 00001000
shiftBits << 2 // 00010000
shiftBits << 5 // 10000000
shiftBits << 6 // 00000000
shiftBits >> 2 // 00000001
비트 시프트를 사용하면 다른 데이터 타입 안의 값을 인코딩하고 디코딩할 수도 있어요:
let pink: UInt32 = 0xCC6699
let redComponent = (pink & 0xFF0000) >> 16 // redComponent is 0xCC, or 204
let greenComponent = (pink & 0x00FF00) >> 8 // greenComponent is 0x66, or 102
let blueComponent = pink & 0x0000FF // blueComponent is 0x99, or 153
이 예시는 pink라는 UInt32 상수를 사용해 분홍색의 CSS(Cascading Style Sheets) 색상 값을 담아요. CSS 색상 값 #CC6699는 Swift의 16진수 숫자 표현에서 0xCC6699로 써요. 그러고 나서 이 색을 비트 AND 연산자(&)와 비트 우 시프트 연산자(>>)로 빨강(CC), 초록(66), 파랑(99) 성분으로 분해해요.
빨강 성분은 0xCC6699와 0xFF0000 사이의 비트 AND를 수행해 얻어요. 0xFF0000의 0들이 0xCC6699의 두 번째와 세 번째 바이트를 효과적으로 "가려서"(mask) 6699를 무시하고 0xCC0000만 남겨요.
이 숫자를 오른쪽으로 16칸 시프트해요(>> 16). 16진수 숫자의 문자 한 쌍은 8비트를 쓰므로, 오른쪽으로 16칸 이동하면 0xCC0000이 0x0000CC로 바뀌어요. 이는 0xCC와 같고, 십진수 값으로 204예요.
비슷하게 초록 성분은 0xCC6699와 0x00FF00 사이의 비트 AND로 얻는데, 출력 값은 0x006600이에요. 이 출력 값을 오른쪽으로 8칸 시프트하면 0x66이 되고, 십진수 값으로 102예요.
마지막으로 파랑 성분은 0xCC6699와 0x0000FF 사이의 비트 AND로 얻는데, 출력 값은 0x000099예요. 0x000099는 이미 0x99와 같고 십진수 값이 153이므로, 오른쪽으로 시프트하지 않고 그대로 써요.
부호 있는 정수의 시프트 동작 (Shifting Behavior for Signed Integers)
부호 있는 정수의 시프트 동작은 이진수로 표현되는 방식 때문에 부호 없는 정수보다 더 복잡해요. (아래 예시는 단순함을 위해 8비트 부호 있는 정수를 기준으로 하지만, 같은 원리가 어떤 크기의 부호 있는 정수에도 적용돼요.)
부호 있는 정수는 첫 번째 비트(이를 *부호 비트(sign bit)*라고 불러요)를 사용해 정수가 양수인지 음수인지를 나타내요. 부호 비트가 0이면 양수, 1이면 음수예요.
나머지 비트(이를 *값 비트(value bits)*라고 불러요)는 실제 값을 저장해요. 양수는 부호 없는 정수와 정확히 같은 방식으로 0부터 위로 세면서 저장돼요. 숫자 4의 Int8 안 비트가 이렇게 생겼어요:
부호 비트는 0("양수"를 뜻함)이고, 일곱 개의 값 비트는 이진 표기로 쓴 숫자 4 그 자체예요.
하지만 음수는 다르게 저장돼요. 음수는 그 절댓값을 2의 n승에서 뺀 값으로 저장하는데, 여기서 n은 값 비트의 개수예요. 8비트 숫자는 값 비트가 일곱 개이므로 2의 7승, 즉 128이 돼요.
숫자 -4의 Int8 안 비트가 이렇게 생겼어요:
이번에는 부호 비트가 1("음수"를 뜻함)이고, 일곱 개의 값 비트는 이진 값 124(128 - 4)를 가져요:
음수를 위한 이 인코딩을 2의 보수(two's complement) 표현이라고 불러요. 음수를 표현하는 다소 특이한 방식처럼 보일 수 있지만, 여러 장점이 있어요.
첫째, -4에 -1을 더할 때 그냥 여덟 비트 모두(부호 비트 포함)에 표준 이진 덧셈을 수행하고, 끝나고 나서 여덟 비트에 들어가지 않는 것은 버리면 돼요:
둘째, 2의 보수 표현 덕분에 음수의 비트도 양수처럼 왼쪽과 오른쪽으로 시프트할 수 있고, 왼쪽으로 한 번 시프트할 때마다 두 배가 되고 오른쪽으로 한 번 시프트할 때마다 절반이 돼요. 이를 위해 부호 있는 정수를 오른쪽으로 시프트할 때 한 가지 추가 규칙이 사용돼요: 부호 있는 정수를 오른쪽으로 시프트할 때는 부호 없는 정수와 같은 규칙을 적용하되, 왼쪽에 남는 빈 비트를 0 대신 부호 비트로 채워요.
이 동작 덕분에 부호 있는 정수가 오른쪽으로 시프트된 뒤에도 같은 부호를 유지하며, 이를 *산술 시프트(arithmetic shift)*라고 불러요.
양수와 음수가 저장되는 특별한 방식 때문에, 둘 중 어느 쪽이든 오른쪽으로 시프트하면 값이 0에 가까워져요. 이 시프트 동안 부호 비트를 그대로 유지하면, 음수 정수는 값이 0에 가까워져도 음수로 남아요.
오버플로 연산자 (Overflow Operators)
정수 상수나 변수에 담을 수 없는 값을 넣으려 하면, 기본적으로 Swift는 잘못된 값이 만들어지게 내버려 두지 않고 오류를 보고해요. 이 동작은 너무 크거나 너무 작은 숫자를 다룰 때 추가적인 안전을 제공해요.
예를 들어 Int16 정수 타입은 -32768부터 32767 사이의 부호 있는 정수를 담을 수 있어요. Int16 상수나 변수에 이 범위 밖의 숫자를 넣으려 하면 오류가 발생해요:
var potentialOverflow = Int16.max
// potentialOverflow equals 32767, which is the maximum value an Int16 can hold
potentialOverflow += 1
// this causes an error
값이 너무 커지거나 작아질 때 오류 처리를 제공하면, 경계 값 조건에 맞춰 코딩할 때 훨씬 유연해져요.
하지만 오버플로 조건이 가용 비트 수를 잘라 버리도록 만들고 싶을 때는, 오류를 일으키는 대신 이런 동작을 선택할 수 있어요. Swift는 정수 계산을 위한 오버플로 동작을 선택하는 세 가지 산술 오버플로 연산자를 제공해요. 이 연산자들은 모두 앰퍼샌드(&)로 시작해요:
- 오버플로 덧셈(
&+) - 오버플로 뺄셈(
&-) - 오버플로 곱셈(
&*)
값 오버플로 (Value Overflow)
숫자는 양의 방향과 음의 방향 모두에서 오버플로할 수 있어요.
부호 없는 정수가 양의 방향으로 오버플로하도록 허용했을 때 무슨 일이 일어나는지, 오버플로 덧셈 연산자(&+)를 써서 보여줄게요:
var unsignedOverflow = UInt8.max
// unsignedOverflow equals 255, which is the maximum value a UInt8 can hold
unsignedOverflow = unsignedOverflow &+ 1
// unsignedOverflow is now equal to 0
unsignedOverflow 변수는 UInt8이 담을 수 있는 최댓값(255, 이진수로 11111111)으로 초기화돼요. 그다음 오버플로 덧셈 연산자(&+)로 1을 증가시켜요. 이렇게 하면 이진 표현이 UInt8이 담을 수 있는 크기를 살짝 넘어서, 아래 다이어그램처럼 자기 경계를 벗어나 오버플로해요. 오버플로 덧셈 후 UInt8의 경계 안에 남는 값은 00000000, 즉 0이에요.
부호 없는 정수가 음의 방향으로 오버플로하도록 허용했을 때도 비슷한 일이 일어나요. 오버플로 뺄셈 연산자(&-)를 쓴 예시를 보여줄게요:
var unsignedOverflow = UInt8.min
// unsignedOverflow equals 0, which is the minimum value a UInt8 can hold
unsignedOverflow = unsignedOverflow &- 1
// unsignedOverflow is now equal to 255
UInt8이 담을 수 있는 최솟값은 0, 즉 이진수로 00000000이에요. 오버플로 뺄셈 연산자(&-)로 00000000에서 1을 빼면, 숫자가 오버플로해 11111111, 즉 십진수 255로 돌아감(wrap around)돼요.
부호 있는 정수에서도 오버플로가 일어나요. 부호 있는 정수의 모든 덧셈과 뺄셈은 비트 방식으로 수행되는데, 더하거나 빼는 숫자에 부호 비트가 포함돼요. 이는 doc:AdvancedOperators#Bitwise-Left-and-Right-Shift-Operators에서 설명한 대로예요.
var signedOverflow = Int8.min
// signedOverflow equals -128, which is the minimum value an Int8 can hold
signedOverflow = signedOverflow &- 1
// signedOverflow is now equal to 127
Int8이 담을 수 있는 최솟값은 -128, 즉 이진수로 10000000이에요. 이 이진수에서 오버플로 연산자로 1을 빼면 이진 값 01111111이 되는데, 이는 부호 비트를 뒤집어 Int8이 담을 수 있는 최대 양수 값인 양수 127을 만들어요.
부호 있는 정수와 부호 없는 정수 모두에서, 양의 방향으로의 오버플로는 유효한 최대 정수 값에서 최솟값으로 돌아가고, 음의 방향으로의 오버플로는 최솟값에서 최댓값으로 돌아가요.
우선순위와 결합성 (Precedence and Associativity)
연산자 *우선순위(precedence)*는 일부 연산자에게 다른 연산자보다 높은 우선권을 주는데, 이 연산자들이 먼저 적용돼요.
연산자 *결합성(associativity)*은 같은 우선순위의 연산자가 어떻게 묶이는지를 정의해요 --- 왼쪽에서 묶이거나, 오른쪽에서 묶이거나. "자신의 왼쪽에 있는 식과 결합한다" 또는 "자신의 오른쪽에 있는 식과 결합한다"를 뜻한다고 생각하면 돼요.
복합 식이 계산될 순서를 따질 때 각 연산자의 우선순위와 결합성을 고려하는 게 중요해요. 예를 들어 연산자 우선순위는 아래 식이 왜 17이 되는지 설명해 줘요.
2 + 3 % 4 * 5
// this equals 17
왼쪽에서 오른쪽으로만 읽는다면, 이 식을 다음과 같이 계산할 거라고 예상할 수 있어요:
2더하기3은55나머지4는11곱하기5는5
하지만 실제 답은 5가 아니라 17이에요. 우선순위가 높은 연산자가 우선순위가 낮은 연산자보다 먼저 계산돼요. Swift에서는 C에서와 마찬가지로 나머지 연산자(%)와 곱셈 연산자(*)가 덧셈 연산자(+)보다 우선순위가 높아요. 결과적으로 두 연산자가 덧셈을 고려하기 전에 먼저 계산돼요.
그런데 나머지와 곱셈은 서로 같은 우선순위예요. 정확한 계산 순서를 알아내려면 결합성도 고려해야 해요. 나머지와 곱셈은 모두 자신의 왼쪽에 있는 식과 결합해요. 식의 이 부분들에 왼쪽부터 시작해 묵시적 괄호를 추가한다고 생각하면 돼요:
2 + ((3 % 4) * 5)
(3 % 4)는 3이므로, 이는 다음과 같아요:
2 + (3 * 5)
(3 * 5)는 15이므로, 이는 다음과 같아요:
2 + 15
이 계산으로 최종 답인 17이 나와요.
Swift 표준 라이브러리가 제공하는 연산자에 대한 정보, 연산자 우선순위 그룹과 결합성 설정의 전체 목록을 포함한 내용은 Operator Declarations 문서를 참고해요.
Note: Swift의 연산자 우선순위와 결합성 규칙은 C나 Objective-C에서보다 더 단순하고 예측 가능해요. 하지만 이는 C 기반 언어와 정확히 같지 않다는 뜻이기도 해요. 기존 코드를 Swift로 포팅할 때는 연산자 상호작용이 여전히 의도한 대로 동작하는지 주의해서 확인해야 해요.
연산자 메서드 (Operator Methods)
클래스와 구조체는 기존 연산자에 대한 나만의 구현을 제공할 수 있어요. 이를 기존 연산자 *오버로딩(overloading)*이라고 불러요.
아래 예시는 커스텀 구조체에 대해 산술 덧셈 연산자(+)를 구현하는 방법을 보여줘요. 산술 덧셈 연산자는 두 대상을 대상으로 하므로 이항(binary) 연산자이고, 두 대상 사이에 나타나므로 중위(infix) 연산자예요.
이 예시는 2차원 위치 벡터 (x, y)를 위한 Vector2D 구조체를 정의하고, 그다음 Vector2D 구조체의 인스턴스끼리 더하는 *연산자 메서드(operator method)*를 정의해요:
struct Vector2D {
var x = 0.0, y = 0.0
}
extension Vector2D {
static func + (left: Vector2D, right: Vector2D) -> Vector2D {
return Vector2D(x: left.x + right.x, y: left.y + right.y)
}
}
연산자 메서드는 오버로드할 연산자(+)와 이름이 같은 타입 메서드로 Vector2D에 정의돼요. 덧셈은 벡터의 본질적인 동작이 아니므로, 타입 메서드는 Vector2D의 기본 구조체 선언 안이 아니라 Vector2D의 확장(extension)에 정의돼요. 산술 덧셈 연산자는 이항 연산자이므로, 이 연산자 메서드는 Vector2D 타입의 입력 매개변수 두 개를 받고 Vector2D 타입의 출력 값도 하나 반환해요.
이 구현에서 입력 매개변수는 + 연산자의 왼쪽과 오른쪽에 놓일 Vector2D 인스턴스를 나타내도록 left와 right라고 이름 지었어요. 메서드는 x와 y 속성이 더해지는 두 Vector2D 인스턴스의 x와 y 속성 합으로 초기화된 새 Vector2D 인스턴스를 반환해요.
이 타입 메서드는 기존 Vector2D 인스턴스들 사이에서 중위 연산자로 사용할 수 있어요:
let vector = Vector2D(x: 3.0, y: 1.0)
let anotherVector = Vector2D(x: 2.0, y: 4.0)
let combinedVector = vector + anotherVector
// combinedVector is a Vector2D instance with values of (5.0, 5.0)
이 예시는 벡터 (3.0, 1.0)과 (2.0, 4.0)을 더해 아래 그림처럼 벡터 (5.0, 5.0)을 만들어요.
전위 및 후위 연산자 (Prefix and Postfix Operators)
위 예시는 이항 중위 연산자의 커스텀 구현을 보여줬어요. 클래스와 구조체는 표준 *단항 연산자(unary operator)*의 구현도 제공할 수 있어요. 단항 연산자는 단일 대상을 다뤄요. 대상 앞에 오면 전위(prefix) 연산자(예: -a)이고, 대상 뒤에 오면 후위(postfix) 연산자(예: b!)예요.
전위 또는 후위 단항 연산자를 구현하려면, 연산자 메서드를 선언할 때 func 키워드 앞에 prefix 또는 postfix 수식어를 쓰면 돼요:
extension Vector2D {
static prefix func - (vector: Vector2D) -> Vector2D {
return Vector2D(x: -vector.x, y: -vector.y)
}
}
위 예시는 Vector2D 인스턴스에 대한 단항 마이너스 연산자(-a)를 구현해요. 단항 마이너스 연산자는 전위 연산자이므로, 이 메서드는 prefix 수식어로 한정해야 해요.
단순한 숫자 값에 대해 단항 마이너스 연산자는 양수를 그에 상응하는 음수로, 음수를 양수로 바꿔요. Vector2D 인스턴스에 대한 상응하는 구현은 x와 y 속성 둘 다에 이 연산을 수행해요:
let positive = Vector2D(x: 3.0, y: 4.0)
let negative = -positive
// negative is a Vector2D instance with values of (-3.0, -4.0)
let alsoPositive = -negative
// alsoPositive is a Vector2D instance with values of (3.0, 4.0)
복합 대입 연산자 (Compound Assignment Operators)
*복합 대입 연산자(compound assignment operator)*는 대입(=)을 다른 연산과 결합해요. 예를 들어 덧셈 대입 연산자(+=)는 덧셈과 대입을 단일 연산으로 결합해요. 복합 대입 연산자의 왼쪽 입력 매개변수 타입은 inout으로 표시하는데, 매개변수 값이 연산자 메서드 안에서 직접 수정되기 때문이에요.
아래 예시는 Vector2D 인스턴스에 대한 덧셈 대입 연산자 메서드를 구현해요:
extension Vector2D {
static func += (left: inout Vector2D, right: Vector2D) {
left = left + right
}
}
덧셈 연산자를 앞서 정의했으므로, 여기서 덧셈 과정을 다시 구현할 필요는 없어요. 대신 덧셈 대입 연산자 메서드는 기존 덧셈 연산자 메서드를 활용해, left 값을 left 더하기 right 값으로 설정해요:
var original = Vector2D(x: 1.0, y: 2.0)
let vectorToAdd = Vector2D(x: 3.0, y: 4.0)
original += vectorToAdd
// original now has values of (4.0, 6.0)
Note: 기본 대입 연산자(
=)는 오버로드할 수 없어요. 복합 대입 연산자만 오버로드할 수 있죠. 마찬가지로 삼항 조건 연산자(a ? b : c)도 오버로드할 수 없어요.
동등 연산자 (Equivalence Operators)
기본적으로 커스텀 클래스와 구조체는 동등 연산자, 즉 같음(equal to) 연산자(==)와 같지 않음(not equal to) 연산자(!=)의 구현이 없어요. 보통 == 연산자를 구현하고, Swift 표준 라이브러리의 != 연산자 기본 구현(이는 == 연산자의 결과를 부정함)을 사용해요. == 연산자를 구현하는 방법은 두 가지예요: 직접 구현하거나, 많은 타입에서 Swift가 구현을 합성(synthesize)하도록 요청할 수 있어요. 두 경우 모두 Swift 표준 라이브러리의 Equatable 프로토콜에 대한 준수를 추가해요.
== 연산자는 다른 중위 연산자를 구현하는 것과 같은 방식으로 구현해요:
extension Vector2D: Equatable {
static func == (left: Vector2D, right: Vector2D) -> Bool {
return (left.x == right.x) && (left.y == right.y)
}
}
위 예시는 두 Vector2D 인스턴스가 동등한 값을 가지는지 확인하는 == 연산자를 구현해요. Vector2D의 맥락에서 "같음"을 "두 인스턴스가 같은 x 값과 y 값을 가짐"으로 보는 게 말이 되므로, 이것이 연산자 구현이 사용하는 논리예요.
이제 이 연산자를 사용해 두 Vector2D 인스턴스가 동등한지 확인할 수 있어요:
let twoThree = Vector2D(x: 2.0, y: 3.0)
let anotherTwoThree = Vector2D(x: 2.0, y: 3.0)
if twoThree == anotherTwoThree {
print("These two vectors are equivalent.")
}
// Prints "These two vectors are equivalent."
많은 단순한 경우에는 doc:Protocols#Adopting-a-Protocol-Using-a-Synthesized-Implementation에서 설명한 것처럼 Swift가 동등 연산자의 합성 구현을 제공해 달라고 요청할 수 있어요.
커스텀 연산자 (Custom Operators)
Swift가 제공하는 표준 연산자에 더해, 나만의 *커스텀 연산자(custom operator)*를 선언하고 구현할 수 있어요. 커스텀 연산자를 정의하는 데 쓸 수 있는 문자 목록은 doc:LexicalStructure#Operators를 참고해요.
새 연산자는 operator 키워드를 사용해 전역 수준에서 선언하고, prefix, infix, postfix 수식어로 표시해요:
prefix operator +++
위 예시는 +++라는 새 전위 연산자를 정의해요. 이 연산자는 Swift에 기존 의미가 없으므로, 아래에서 Vector2D 인스턴스를 다루는 특정 맥락에서 자신만의 커스텀 의미를 부여받아요. 이 예시의 목적을 위해 +++는 새 "전위 두 배 곱하기(prefix doubling)" 연산자로 취급돼요. 앞서 정의한 덧셈 대입 연산자로 벡터를 자기 자신에 더해, Vector2D 인스턴스의 x와 y 값을 두 배로 만들죠. +++ 연산자를 구현하려면 다음과 같이 Vector2D에 +++라는 타입 메서드를 추가해요:
extension Vector2D {
static prefix func +++ (vector: inout Vector2D) -> Vector2D {
vector += vector
return vector
}
}
var toBeDoubled = Vector2D(x: 1.0, y: 4.0)
let afterDoubling = +++toBeDoubled
// toBeDoubled now has values of (2.0, 8.0)
// afterDoubling also has values of (2.0, 8.0)
커스텀 중위 연산자의 우선순위 (Precedence for Custom Infix Operators)
커스텀 중위 연산자는 각각 우선순위 그룹(precedence group)에 속해요. 우선순위 그룹은 다른 중위 연산자에 대한 연산자의 상대적 우선순위와 연산자의 결합성을 지정해요. 이러한 특성이 중위 연산자의 다른 중위 연산자와의 상호작용에 어떤 영향을 주는지는 doc:AdvancedOperators#Precedence-and-Associativity에 설명돼 있어요.
우선순위 그룹에 명시적으로 넣지 않은 커스텀 중위 연산자는, 삼항 조건 연산자의 우선순위보다 한 단계 바로 위인 우선순위를 가진 기본 우선순위 그룹을 받아요.
다음 예시는 AdditionPrecedence 우선순위 그룹에 속하는 +-라는 새 커스텀 중위 연산자를 정의해요:
infix operator +-: AdditionPrecedence
extension Vector2D {
static func +- (left: Vector2D, right: Vector2D) -> Vector2D {
return Vector2D(x: left.x + right.x, y: left.y - right.y)
}
}
let firstVector = Vector2D(x: 1.0, y: 2.0)
let secondVector = Vector2D(x: 3.0, y: 4.0)
let plusMinusVector = firstVector +- secondVector
// plusMinusVector is a Vector2D instance with values of (4.0, -2.0)
이 연산자는 두 벡터의 x 값을 더하고, 첫 번째 벡터에서 두 번째 벡터의 y 값을 뺀 값을 구해요. 본질적으로 "덧셈 성격"의 연산자이므로, +와 - 같은 덧셈 계열 중위 연산자와 같은 우선순위 그룹을 부여받았어요. Swift 표준 라이브러리가 제공하는 연산자에 대한 정보, 연산자 우선순위 그룹과 결합성 설정의 전체 목록은 Operator Declarations 문서를 참고해요. 우선순위 그룹에 대한 더 자세한 정보와 나만의 연산자, 우선순위 그룹을 정의하는 문법은 doc:Declarations#Operator-Declaration을 참고해요.
Note: 전위 또는 후위 연산자를 정의할 때는 우선순위를 지정하지 않아요. 하지만 같은 피연산자에 전위 연산자와 후위 연산자를 모두 적용하면, 후위 연산자가 먼저 적용돼요.
결과 빌더 (Result Builders)
*결과 빌더(result builder)*는 목록이나 트리 같은 중첩 데이터를 자연스럽고 선언적인 방식으로 만들기 위한 문법을 추가하는, 여러분이 정의하는 타입이에요. 결과 빌더를 사용하는 코드에는 조건적이거나 반복적인 데이터 조각을 다루기 위해 if와 for 같은 일반적인 Swift 문법을 포함할 수 있어요.
아래 코드는 별표와 텍스트를 사용해 한 줄에 그리는 몇 가지 타입을 정의해요.
protocol Drawable {
func draw() -> String
}
struct Line: Drawable {
var elements: [Drawable]
func draw() -> String {
return elements.map { $0.draw() }.joined(separator: "")
}
}
struct Text: Drawable {
var content: String
init(_ content: String) { self.content = content }
func draw() -> String { return content }
}
struct Space: Drawable {
func draw() -> String { return " " }
}
struct Stars: Drawable {
var length: Int
func draw() -> String { return String(repeating: "*", count: length) }
}
struct AllCaps: Drawable {
var content: Drawable
func draw() -> String { return content.draw().uppercased() }
}
Drawable 프로토콜은 선이나 모양처럼 그릴 수 있는 무언가의 요구사항을 정의해요: 해당 타입은 draw() 메서드를 구현해야 해요. Line 구조체는 한 줄짜리 그림을 나타내며, 대부분의 그림에서 최상위 컨테이너 역할을 해요. Line을 그리려면 구조체가 줄의 각 구성 요소에 draw()를 호출하고, 결과 문자열들을 단일 문자열로 연결해요. Text 구조체는 문자열을 감싸 그림의 일부로 만들어요. AllCaps 구조체는 다른 그림을 감싸고 수정해서, 그림 안의 텍스트를 대문자로 바꿔요.
이 타입들의 초기화 메서드를 호출하면 그림을 만들 수 있어요:
let name: String? = "Ravi Patel"
let manualDrawing = Line(elements: [
Stars(length: 3),
Text("Hello"),
Space(),
AllCaps(content: Text((name ?? "World") + "!")),
Stars(length: 2),
])
print(manualDrawing.draw())
// Prints "***Hello RAVI PATEL!**".
이 코드는 동작하지만 조금 어색해요. AllCaps 뒤의 깊게 중첩된 괄호는 읽기 어렵죠. name이 nil일 때 "World"를 사용하는 폴백 로직을 ?? 연산자를 사용해 인라인으로 처리해야 하고, 더 복잡한 것에서는 어려울 거예요. 그림의 일부를 만들기 위해 switch나 for 루프를 포함해야 한다면 그렇게 할 방법이 없어요. 결과 빌더를 사용하면 이런 코드를 일반적인 Swift 코드처럼 보이게 다시 쓸 수 있어요.
결과 빌더를 정의하려면 타입 선언에 @resultBuilder 속성을 쓰면 돼요. 예를 들어 아래 코드는 그림을 선언적 문법으로 표현할 수 있게 해 주는 DrawingBuilder라는 결과 빌더를 정의해요:
@resultBuilder
struct DrawingBuilder {
static func buildBlock(_ components: Drawable...) -> Drawable {
return Line(elements: components)
}
static func buildEither(first: Drawable) -> Drawable {
return first
}
static func buildEither(second: Drawable) -> Drawable {
return second
}
}
DrawingBuilder 구조체는 결과 빌더 문법의 일부를 구현하는 메서드 세 개를 정의해요. buildBlock(_:) 메서드는 코드 블록 안에 일련의 줄을 쓰는 것을 지원하도록 추가해요. 블록 안의 구성 요소들을 Line으로 결합하죠. buildEither(first:)와 buildEither(second:) 메서드는 if-else를 지원하도록 추가해요.
@DrawingBuilder 속성을 함수의 매개변수에 적용하면, 함수에 전달된 클로저가 결과 빌더가 그 클로저에서 만드는 값으로 바뀌어요. 예를 들어:
func draw(@DrawingBuilder content: () -> Drawable) -> Drawable {
return content()
}
func caps(@DrawingBuilder content: () -> Drawable) -> Drawable {
return AllCaps(content: content())
}
func makeGreeting(for name: String? = nil) -> Drawable {
let greeting = draw {
Stars(length: 3)
Text("Hello")
Space()
caps {
if let name = name {
Text(name + "!")
} else {
Text("World!")
}
}
Stars(length: 2)
}
return greeting
}
let genericGreeting = makeGreeting()
print(genericGreeting.draw())
// Prints "***Hello WORLD!**".
let personalGreeting = makeGreeting(for: "Ravi Patel")
print(personalGreeting.draw())
// Prints "***Hello RAVI PATEL!**".
makeGreeting(for:) 함수는 name 매개변수를 받아 그것으로 개인화된 인사말을 그려요. draw(_:)와 caps(_:) 함수는 모두 인자로 단일 클로저를 받는데, 이 클로저는 @DrawingBuilder 속성으로 표시돼요. 이 함수들을 호출할 때는 DrawingBuilder가 정의하는 특별한 문법을 사용해요. Swift는 그림에 대한 그 선언적 설명을 DrawingBuilder의 메서드들에 대한 일련의 호출로 변환해, 함수 인자로 전달되는 값을 만듭니다. 예를 들어 Swift는 그 예시에서 caps(_:)에 대한 호출을 다음과 같은 코드로 변환해요:
let capsDrawing = caps {
let partialDrawing: Drawable
if let name = name {
let text = Text(name + "!")
partialDrawing = DrawingBuilder.buildEither(first: text)
} else {
let text = Text("World!")
partialDrawing = DrawingBuilder.buildEither(second: text)
}
return partialDrawing
}
Swift는 if-else 블록을 buildEither(first:)와 buildEither(second:) 메서드에 대한 호출로 변환해요. 여러분 자신의 코드에서는 이 메서드를 직접 호출하지 않지만, 변환 결과를 보여주면 DrawingBuilder 문법을 사용할 때 Swift가 여러분의 코드를 어떻게 변환하는지 더 쉽게 볼 수 있어요.
특별한 그림 문법에서 for 루프를 쓰는 것을 지원하도록 buildArray(_:) 메서드를 추가해요.
extension DrawingBuilder {
static func buildArray(_ components: [Drawable]) -> Drawable {
return Line(elements: components)
}
}
let manyStars = draw {
Text("Stars:")
for length in 1...3 {
Space()
Stars(length: length)
}
}
위 코드에서 for 루프는 그림들의 배열을 만들고, buildArray(_:) 메서드가 그 배열을 Line으로 바꿔요.
Swift가 빌더 문법을 빌더 타입의 메서드 호출로 변환하는 방법의 전체 목록은 doc:Attributes#resultBuilder를 참고해요.
더 알아보기
- 비트 연산자:
~NOT,&AND,|OR,^XOR,<</>>시프트. 부호 없는 정수는 논리 시프트, 부호 있는 정수는 부호 비트를 채우는 산술 시프트를 써요. - 음수는 2의 보수로 저장되어, 모든 비트를 더하거나 시프트해도 부호가 유지돼요.
- 기본 산술 연산자는 오버플로 시 오류를 내지만,
&+,&-,&*오버플로 연산자는 비트를 잘라 버리고 최대↔최소로 돌아가요(wrap around). - 연산자 우선순위와 결합성은 복합 식의 계산 순서를 정해요. 나만의 중위 연산자는 우선순위 그룹으로 묶을 수 있어요.
- 클래스/구조체는 기존 연산자를 오버로드하는 연산자 메서드(
static func)를 정의하거나,operator키워드로 새 커스텀 연산자를 만들 수 있어요. - 결과 빌더(
@resultBuilder)는if,for같은 일반 Swift 문법으로 중첩 데이터를 선언적으로 만들게 해 줘요. - 관련 주제로는 연산자 오버로딩의 바탕이 되는 "속성", 결과 빌더 변환 규칙을 다루는 "속성"(@resultBuilder) 문서가 이어져요.