[Techris] 3. Deep Dive into Smalltalk
Techris 개발기 — Smalltalk의 동작원리와 문법을 알아보며
들어가며
Pharo와 Smalltalk의 동작원리와 문법을 학습하면서 배웠던 내용을 정리하려고 한다. 우리나라에선 거의 사용되지 않아 한글로된 레퍼런스가 아예 없어서 어떻게 학습을 해야할까 여러 자료들을 찾아봤고 다행히다 Pharo에서 공식적으로 문법을 설명해준 pdf들이 일목요연하게 잘 정리가 되어있었다. 이를 바탕으로 테크리스를 구현하기 위해 코드를 작성했던 것들을 기록하려고 한다. 다행히도 Java 프로그래머를 위한 pdf도 있어서 진도가 꽤 빨리 나갈 것 같다.
Pharo의 철학과 동작 원리
이미지 기반 개발 환경
Java나 Python에서 코드를 작성하면 소스 파일(.java, .py)을 저장하고 컴파일하거나 실행하는 방식이 내가 지금까지 사용해왔던 방식이었다. 하지만 Smalltalk과 Pharo는 근본적으로 다르게 설계가 되어있는데, 코드가 파일이 아니라 살아있는 객체 세계 안에 존재한다는 것이다.
Pharo를 실행하면 .image 파일이 메모리에 로드되는데, 이 이미지 파일 안에는 모든 클래스, 객체, 실행 중인 프로세스, 심지어 IDE의 윈도우 위치까지 저장되어 있다. 코드를 수정하고 Save를 누르면 파일이 아니라 이미지 전체가 스냅샷으로 저장된다.
1
2
"Playground에서 실행"
Smalltalk snapshot: true andQuit: false.
이 한 줄이 현재 메모리 상태를 통째로 디스크에 저장한다. 다음에 Pharo를 열면 정확히 그 스냅샷을 찍은 시점으로 돌아간다.
VM 위에서 실행되는 객체들
Pharo는 Pharo VM 위에서 실행된다. Java의 JVM과 비슷하지만 더 근본적인 차이가 있다. JVM은 바이트코드를 실행하는 머신이지만 Pharo VM은 객체 세계 자체를 실행하는 VM이다. 이미지 파일과 VM의 동작 과정을 도식화 하면 아래와 같다.
1
2
3
4
5
6
7
[Disk] .image 파일
↓ load
[Memory] 살아있는 객체들
↓
[Pharo VM] 실행 + Compile
↓
[Screen] IDE + 내 앱
따라서 Pharo에서는 실행 중에 코드를 수정하고 즉시 반영할 수 있다. 디버거에서 멈춘 상태로 메서드를 고치면 그대로 실행이 재개된다.
Everything is an Object
Java에서는 primitive 타입(int, boolean)과 객체가 구분된다:
1
2
3
int x = 5; // primitive
Integer y = 5; // object
boolean flag = true; // primitive
하지만 Pharo에서는 primitive 타입과 같은 분류가 전혀 이루어 지지 않고 모든 것이 객체라고 한다. 숫자, boolean값, 클래스, 메서드도 모두 객체다.
요약하자면 모든 요소들이 전부 객체로 이루어져있기 때문에 Pharo에서는 messege를 주고 받는 방법으로 동작하게 된다.
단순히 5 + 3 이라는 식을 smalltalk에서는 숫자 5에게 + 메시지를 보낸다. 라는 방식으로 동작하고 클래스에서의 new 키워드 또한 Person 클래스에게 new라는 메시지를 보낸다. 라는 방식으로 동작하게 된다.
1
2
3
5 + 3. "숫자 5에게 + 메시지 전송"
Person new. "Person 클래스에게 new 메시지 전송"
Pharo Syntax
1. 변수 선언과 할당
Java와는 변수를 선언하고 할당하는 방식이 많이 다르다. 코드를 보면 | | 사이에 지역변수로 선언할 것들을 미리 적어두고 아래에서 할당 연산자로 할당하는 방식이다. 할당 연산자 또한 java랑은 다른데 =이 아닌 :=를 사용한다. =은 비교연산자로 예약되어있기 떄문. 또한 java 에서 final과 같은 개념이 없고 대신 메서드로 감싸서 읽기 전용처럼 사용된다. 더 객체지향적으로 만들었다는데 정말 어렵다.
1
2
3
String name = "choi";
int age = 99;
final double PI = 3.14;
Pharo에서는:
1
2
3
4
5
6
7
"지역 변수"
| name age |
name := 'choi'.
age := 99.
PI
^ 3.14
2. 주석
"를 사용해서 주석을 작성하는데 일단 Pharo의 기본 폰트는 CJK를 지원하지 않아 한글로 주석을 작성해도 모든게 다 깨져버린다. 나중에 전용 폰트를 받아서 코드를 작성해야할 것 같다.
1
2
"주석입니다"
x := 5. "인라인 주석"
3. Messages
위에서 얘기했던 메시지의 차례이다. 그렇다면 여기서 메시지는 무엇일까? 메시지는 기본적으로 우리가 사용하는 메서드라고이해하면 될 것 같다. 이 개념이 문법을 학습하면서 제일 어려웠던 개념이었고 실제로 많은 시간을 쏟았던 파트이기도 하다.
예를 들어보자면 Java에서는 아래와 같이 정의한 메서드를 호출하는 방식으로 동작한다.
1
2
3
person.getName()
list.add(item)
Math.sqrt(16)
반면 Pharo에서는 아래처럼 메시지를 전송하는 방식으로 동작하게 되는데 이 차이는 Java의 메서드 호출은 컴파일 타임에 정적으로 바인딩되지만, Smalltalk의 메시지 전송은 런타임에 수신자가 처리 방법을 결정한다. 문서에서는 이 방식이 있기에 다른 언어들 보다 다형성이 극대화될 수 있는 언어라고 이야기 한다. 하지만 러닝커브는..
4. 메시지의 세 가지 형태
메시지는 세 개의 종류로 나눠진다.
- 가장 단순히 파라미터가 없는 Unary Messages
- 연산자가 있는 Binary Messages
- 파라미터가 있는 Keyword Messages
호출 순서는 unary > binary > keyword로 동작한다.
Unary Messages (파라미터 없음)
Unary Messages는 Java의 getter 메서드 처럼 인자 없는 메서드와 같다고 보면 된다.
1
2
person name. "getter"
5 factorial.
Binary Messages (연산자)
위에서 얘기했던 것 처럼 연산자도 하나의 메세지로 분류가 되기 떄문에 아래 코드에서 3에게 + 메시지를, 10에게 - 메시지를 보내는 방식이다.
1
2
3
3 + 4.
10 - 5.
'Hello' , ' World'.
Keyword Messages (파라미터 있음)
키워드 메시지는 인자가 있는 메시지다. 콜론(:)이 인자를 받는다는 표시다. name:age:는 두 개의 인자를 받는 하나의 메시지다. 처음에는 name:과 age:가 두 개의 메시지인 줄 알았는데, 실제로는 name:age: 전체가 하나의 메서드 이름이다. 메서드 이름에 인자 위치가 표시되어 있는 셈이다.
1
2
3
person.setNameAndAge("choi", 99)
array.set(1, "hello")
dict.getOrDefault("key", "default")
1
2
3
person name: 'choi' age: 99.
array at: 1 put: 'hello'.
dict at: 'key' ifAbsent: [ 'default' ].
5. 숫자와 연산
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"정수"
5 + 3.
10 / 3.
10 // 3.
10 \\ 3.
"제곱"
2 raisedTo: 8.
"절댓값"
-5 abs.
"최대/최소"
3 max: 7.
3 min: 7.
"범위 체크"
5 between: 1 and: 10. "return true"
6. 비교 연산자
비교 연산자는 Java와 정 반대인데 Java에서는 ==가 참조 비교고 .equals()가 값 비교인데, Pharo에서는 =가 값 비교고 ==가 참조 비교다. 또한 ~=는 not equal이고 ~ 자체를 부정의 의미로 쓰는 것 같다.
1
2
3
4
5
6
7
x = y. "값 비교 (equals)"
x == y. "참조 비교 (identity)"
x ~= y. "not equal"
x > y.
x >= y.
x < y.
x <= y.
7. 논리 연산자
여기서는 논리 연산자도 메시지로 취급한다. &와 |는 binary message고, and:와 or:는 keyword message다.
중요한 차이는 &와 |는 양쪽을 다 평가하지만(eager evaluation), and:와 or:는 블록을 받아서 필요할 때만 평가한다(short-circuit). Java의 &&, ||와 같은 동작을 원하면 and:, or:를 써야 한다.
그리고 부정은 !가 아니라 not이라는 메서드다. flag not처럼 메시지를 보내는 형태라서 처음에는 어색했지만, “모든 게 메시지”라는 Smalltalk 철학을 생각하면 일관성 있는 디자인 같다.
1
2
3
4
5
6
7
(x > 0) & (y > 0).
(x > 0) | (y > 0).
flag not.
"short-circuit"
(x > 0) and: [ y > 0 ].
(x > 0) or: [ y > 0 ].
8. 문자열
Pharo에서 문자열은 작은따옴표 '를 쓴다.
그리고 Java랑 가장 큰 차이점은 인덱스가 1부터 시작한다. 대부분의 현대 언어가 0-based indexing을 쓰는데 Pharo는 1-based다.
문자열 연결은 , 연산자를 쓰고 이거 또한 binary message다.
1
2
3
4
5
6
7
8
9
s := 'Hello'.
s size.
s at: 1.
s copyFrom: 2 to: 4.
s asUppercase.
s, ' World'.
"문자열 포맷"
'Hello {1}!' format: { 'World' }.
9. Point와 Rectangle
1
2
3
4
5
6
7
8
9
10
11
12
13
"Point 생성"
p := 10@20.
p x.
p y.
p + (5@5).
"Rectangle"
rect := 10@10 corner: 100@100.
rect := 10@10 extent: 90@90. "같은 결과"
rect width.
rect height.
rect center.
Java에서는 지원하지 않는 Point가 있는데 @는 Point를 만드는 연산자다. 10@20은 x=10, y=20인 Point 객체를 생성한다. 테크리스를 만들 때 아무래도 좌표에 대한 처리가 제일 핵심인 만큼 이게 있으면 매우 편리해질 것 같다.
10. 블록 (클로저)
블록은 [ ]로 감싸고, 파라미터는 :x |로 선언한다.
1
2
3
4
5
6
7
"인자가 여러 개"
[ :a :b | a + b ] value: 3 value: 4.
"조건문도 블록"
x > 0
ifTrue: [ 'positive' ]
ifFalse: [ 'negative' ].
블록은 일급 객체라고 한다. 변수에 할당할 수 있고, 인자로 전달할 수 있고, 나중에 실행할 수 있다. Java의 람다와 비슷하다고 보면 될 것 같다.
11. 조건문
여기서 조건문은 아래 코드처럼 작성이 되는데 ifTrue:ifFalse:는 Boolean 객체의 메서드이다. true와 false는 객체고, 이들이 ifTrue:ifFalse: 메시지를 받는다? 라고 이해하면 될 것 같다.
1
2
3
x > 0
ifTrue: [ ^ 'positive' ]
ifFalse: [ ^ 'negative' ].
12. nil 처리
Java에서는 null이 특수한 값이라서 if (obj == null)로 체크해야 하는데, Pharo에서는 nil 자체가 NilObject의 유일한 인스턴스인 객체다.
그래서 nil에게 ifNil: 메시지를 보낼 수 있다. 심지어 nil에게 다른 메시지를 보내면 MessageNotUnderstood 에러가 나는데, 이것도 예외 객체이다.
1
2
3
4
if (obj == null) {
return "default";
}
return obj.toString();
1
2
3
4
5
6
7
obj ifNil: [ ^ 'default' ].
obj ifNotNil: [ ^ obj printString ].
"둘 다"
obj
ifNil: [ 'default' ]
ifNotNil: [ :value | value printString ].
13. 반복문
Java의 for는 키워드지만 Pharo에서는 숫자 객체의 메서드다. 1 to: 5는 Range 객체를 만들고, do:에 블록을 전달해서 반복한다. 1부터 5를 Transcript에 보여주는 반복문을 작성하면 아래와 같이 작성할 수 있다.
1
2
3
1 to: 5 do: [ :i |
Transcript show: i printString; cr
].
14. 컬렉션
Array (리터럴)
#( )로 리터럴 배열을 만든다. Java의 배열 리터럴과 비슷하지만 불변이다.
1
2
3
4
5
6
#(1 2 3 4 5) "리터럴 배열"
#('a' 'b' 'c') "문자열 배열"
"access"
arr := #(10 20 30).
arr at: 1.
OrderedCollection (동적 배열)
1
2
3
4
5
list := OrderedCollection new.
list add: 'hello'.
list add: 'world'.
list at: 1.
list size.
Java의 ArrayList와 같다. 동적으로 크기가 변하는 배열이다. 리터럴 배열과 달리 수정 가능하다.
Dictionary
1
2
3
4
5
6
7
8
9
dict := Dictionary new.
dict at: 'name' put: 'choi'.
dict at: 'age' put: 99.
dict at: 'name'.
dict at: 'email' ifAbsent: [ 'N/A' ].
"리터럴"
#{ 'name' -> 'choi' . 'age' -> 99 }
Java의 HashMap과 같다. at:put:으로 저장하고 at:으로 조회한다.
at:ifAbsent: 같은 메서드가 있어서 키가 없을 때 기본값을 지정할 수 있다. Java의 getOrDefault()와 같은데, 블록을 받아서 더 유연하다.
15. 컬렉션 고차 함수
Pharo또한 컬렉션 API 함수형 프로그래밍을 잘 지원하고 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
arr := #(1 2 3 4 5).
"collect (map)"
arr collect: [ :x | x * 2 ].
"=> #(2 4 6 8 10)"
"select (filter)"
arr select: [ :x | x > 3 ].
"=> #(4 5)"
"reject (filter의 반대)"
arr reject: [ :x | x > 3 ].
"=> #(1 2 3)"
"detect (find)"
arr detect: [ :x | x > 3 ].
"=> 4"
"inject:into: (reduce)"
arr inject: 0 into: [ :sum :x | sum + x ].
"=> 15"
"do: (forEach)"
arr do: [ :x | Transcript show: x; cr ].
"allSatisfy: (every)"
arr allSatisfy: [ :x | x > 0 ].
"=> true"
"anySatisfy: (some)"
arr anySatisfy: [ :x | x > 10 ].
"=> false"
16. Transcript (콘솔 출력)
Java의 System.out.println과 같은 역할인 Transcript를 지원한다. Pharo IDE 안에 Transcript 윈도우가 따로 있어서 거기에 출력된다.
cr은 “carriage return”의 약자로 줄바꿈이다. 세미콜론 ;으로 cascade를 쓰면 같은 Transcript 객체에게 연속으로 메시지를 보낼 수 있다.
1
2
3
4
5
6
7
Transcript show: 'Hello'; cr.
Transcript show: 'World'.
"여러 줄"
Transcript
show: 'Line 1'; cr;
show: 'Line 2'; cr.
17. 클래스 정의
Pharo에서는 파일이 아니라 브라우저에서 클래스를 정의한다. <<는 상속을 의미하고, slots는 인스턴스 변수다. Java랑 다르게 Pharo는 IDE의 System Browser에서 직접 클래스를 만든다. 그리고 저장하면 이미지 파일에 통째로 들어간다. 또한 Java와 같이 Object 클래스가 최상의 클래스이고 모든 클래스는 이를 상속 받아서 사용하게 된다. 또한 모든 클래스와 메서드는 System browser에서 작성하고 저장하게끔 된다.
1
2
3
4
"System Browser에서"
Object << #Person
slots: { #name };
package: 'Practice'
18. 인스턴스 변수와 접근자
Pharo에서는 인스턴스 변수가 자동으로 private이다. Java처럼 private, public, protected 키워드가 없다. 모든 인스턴스 변수는 기본적으로 외부에서 접근할 수 없고, 접근하려면 반드시 getter/setter 메서드를 만들어야 한다. getter와 setter 또한 하나의 메서드이기 때문에 메서드를 만들면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
"클래스 정의"
Object << #Person
slots: { #name };
package: 'Practice'
"Getter"
name
^ name
"Setter"
name: aString
name := aString
19. self와 super
self는 Java의 this와 같다. 현재 객체 자신을 가리킨다. 하지만 Pharo에서는 메서드가 명시적으로 return하지 않으면 자동으로 self를 리턴한다.
따라서 setter 메서드들이 대부분 아무것도 리턴하지 않아도 자동으로 self를 리턴해서 method chaining이 가능하다. Java에서 builder 패턴 쓸 때 매번 return this;를 써야 하는 것과 비교하면 훨씬 깔끔하다.
1
2
3
4
5
6
7
8
9
10
11
Person << #Student
slots: { #grade };
package: 'MyApp'
"self = this"
info
^ super info, ', Student'
test
self name.
^ self
21. 클래스 메서드 (static)
Pharo에는 static 키워드가 없다. 대신 class side라는 개념이 있다. 클래스 자체도 객체고, 그 클래스에게 메시지를 보낼 수 있다.
System Browser에서 “class side” 버튼을 누르면 클래스 메서드를 정의할 수 있고 이게 Java의 static method와 같은 역할을 한다. 하지만 개념적으로는 “클래스 객체의 인스턴스 메서드”라고 보는 게 정확하다. 우리가 일반적으로 사용하는 것은 인스턴스 메서드, 그게 아니면 클래스 메서드. 중간에 inst.side, Class side로 나누어진 걸 볼 수 있는데 누르면서 바꾸면 된다.
1
2
3
4
5
6
"Person class >> create:"
create: aName
^ self new
name: aName;
yourself
22. Cascade
;는 같은 객체에게 연속으로 메시지를 보낼 수 있게 한다.
1
2
3
4
5
sb := WriteStream on: String new.
sb
nextPutAll: 'Hello';
space;
nextPutAll: 'World'.
23. Yourself
1
2
3
4
5
6
7
8
9
10
Java의 builder 패턴
person := Person new.
person name: 'choi'.
person age: 99.
Pharo - cascade
person := Person new
name: 'choi';
age: 99;
yourself. "마지막에 객체 자체 리턴"
Cascade(;)를 쓰면 같은 객체에게 계속 메시지를 보내는데, 문제는 마지막 메시지의 결과가 리턴된다. 예를 들어 person age: 99의 결과는 99가 아니라 person이 되어야 하는데, cascade의 마지막이 age: 99면 99가 리턴될 수 있다.
그래서 마지막에 yourself를 붙여서 “객체 자체를 리턴해줘”라고 명시하는 것이다. Java의 builder 패턴에서 return this;를 쓰는 것과 비슷한 역할이다.
24. 예외 처리
예외 처리도 블록과 메시지로 한다. Java의 try-catch처럼 특수한 키워드가 아니라, 블록에 on:do: 메시지를 보내면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
[
self riskyOperation
] on: Error do: [ :ex |
self handleError: ex
].
"ensure (finally)"
[
self riskyOperation
] ensure: [
self cleanup
].
마치며
학습하는데 1주일이나 걸려서 빠르게 구현을 해봐야할 것 같다. 코드를 많이 쳐보니 어느정도 감을 잡은 것 같긴한데 계속 쳐보면서 되는 코드와 안되는 코드를 기억해내면서 개발해야할 것 같다.






