Dart 언어 마스터
Dart 언어에 대한 보다 심화된 내용을 정리해봤습니다.읽는데 17분 정도 걸려요.factory constructor
factory
키워드를 사용하면 아래와 같은 기능을 구현할 수 있다고 공식문서에 나와 있습니다.
- 인스턴스를
캐시에서 반환
하여 클래스의 인스턴스를 한 번만 생성할 수 있도록 합니다. (일종의 Singleton 패턴 구현법) - 생성자에서
서브 클래스
(상속 받은 클래스)의 인스턴스를 반환할 수 있습니다.
둘 다 신기한 기능입니다. (다만 언제 사용하는게 적재적소인지는 잘 모르겠네요..)
1번부터 알아봅시다.
caching
class Singleton {
static final Singleton _instance = Singleton._internal();
Singleton._internal() {}
factory Singleton() {
return _instance;
}
void getHash() {
print(this.hashCode);
}
}
void main() {
Singleton s1 = new Singleton();
Singleton s2 = new Singleton();
s1.getHash(); // 302000605
s2.getHash(); // 302000605
}
위와 같이 구현한다면, Singleton 클래스의 인스턴스는 단 한개
만 생성되게 됩니다.
클래스 로드 시점에 static으로 이미 인스턴스가 만들어지기 때문이죠.
하지만 이런 사용용법은 의미가 없어보입니다.
차라리 아래의 예시가 더 의미 있어보입니다.
class SingletonLog {
final String value;
static final Map<String, SingletonLog> _cache = {};
SingletonLog._internal(this.value);
factory SingletonLog(String value) {
return _cache.putIfAbsent(value, () => SingletonLog._internal(value));
}
void getHash() {
print(this.hashCode);
}
String getValue() {
return value;
}
Map<String, String> getLogs() {
Map<String, String> result = {};
_cache.forEach((key, value) {
result[key] = value.value;
});
return result;
}
}
void main() {
SingletonLog s1 = new SingletonLog('log1');
SingletonLog s2 = new SingletonLog('log2');
s1.getHash(); // 888013580
s2.getHash(); // 870182876
print(s1.value); // log1
print(s2.value); // log2
print(s1.getLogs()); // {log1: log1, log2: log2}
print(s2.getLogs()); // {log1: log1, log2: log2}
}
이렇게 하면 s1, s2가 같은 인스턴스를 반환받기 때문에, 어디에서 로그를 추가해도 모든 객체가 같은 로그기록을 보관할 수 있습니다.
그럼 이런 의문이 듭니다.
hashCode가 다르게 나오는데, 그럼 다른 객체 아닌가요?
실제로 s1 == s2의 값은 false
입니다. 따라서 다른 객체
가 맞습니다.
하지만 약간 변형이 이루어지긴 했으나, 싱글톤 패턴
역시 맞습니다.
왜냐하면, 인스턴스의 생성을 클래스 내부에서 _internal
로 생성하고, 생성자를 외부에서 호출하지 못합니다.
또한, static으로 선언된 _cache
내부에서 인스턴스를 보관하기 때문에 _cache를 통해서 전역적으로 인스턴스를 공유하게 됩니다.
그렇다고 항상 다른 객체를 반환하는 것은 아닙니다.
실제로, s2를 생성할 때, s1과 동일하게 'log1'
을 주입시키면 s1, s2 동일한 hashCode를 갖게 됩니다.
왜냐하면, factory 생성자는 일단 캐시되어있는 인스턴스를 반환하기 때문에,
_cache 내부에 인스턴스를 저장하고 있고, s1, s2 모두 같은 데이터를 갖고 있기에 메모리주소 역시 같아집니다.
return subclass constructor
그럼 2번도 알아봐야겠죠?
class Human {
final String name;
Human(this.name);
factory Human.create(String name, {required bool isMan}) {
if (isMan) {
return Male(name);
}
return Female(name);
}
}
class Male extends Human {
Male(String name) : super(name);
}
class Female extends Human {
Female(String name) : super(name);
}
void main() {
Human man = new Human.create('h1', isMan: true);
Human woman = new Human.create('h2', isMan: false);
print(man.name); // h1
print(woman.name); // h2
}
이 방식은 객체지향 프로그래밍의 다형성의 원리를 충족시키기에 좋은 문법입니다.
신기한 점은 부모 클래스 생성자에서 자식 클래스의 생성자를 호출해 반환한다는 점입니다.
이렇게하면 인스턴스 생성 시점에 조건을 달아 다른 인스턴스를 반환하는 것이 가능해집니다.
(상태나 이벤트를 클래스로 관리할 때 유용해 보이네요!)
constructor body
사실 공식문서에는 자세히 설명하진 않지만, factory 생성자의 가장 큰 이점은 final variable
을 생성자에서 initialize list
로 초기화하지 않아도 된다는 점 이라고 생각합니다.
정확히 말하자면, 생성자를 내부에서 호출함으로서 생성자 호출 전, 전처리
를 할 수 있다는 점이 이점이라고 생각합니다.
예시를 봐야겠죠?
class Human {
final String name;
Human(this.name);
// Human.withSir(String name) : this.name = 'Sir - ' + name;
factory Human.withSir(String name) {
String newName = 'Sir - ' + name;
return Human(newName);
}
}
void main() {
Human man = new Human.withSir('h1');
print(man.name);
}
위 코드에서 주석처리 된 부분과 하이라이트 된 부분 모두 동일한 기능을 수행합니다.
지금은 간단하게 문자열 앞에 Sir -
을 붙이도록 했지만, 만약 배열을 직렬화 해야하는 상황이라면?
한 줄로 처리하기 복잡한 기능은 분명 일반 생성자의 initialize list로 처리하는덴 한계가 있을 것입니다.
한 줄로 처리하기 복잡한 코드는 예로들어 json serialize
부분이 있겠죠?
그래서 우리가 무의식적으로 사용하던 factory가 사실은 인스턴스 생성을 편-안하게 생성하기 위해 사용했던 것이었습니다!
get, set
getter, setter 메서드를 이용해서 객체의 가상 멤버변수(property
)를 읽고 쓰는게 가능해진다는 건 다른 객체지향 언어에도 있는 기능입니다.
dart에선 이를 가능케 하는 키워드가 get
, set
입니다.
공식문서에 있는 코드를 분석해봅시다.
class Rectangle {
double left, top, width, height;
Rectangle(this.left, this.top, this.width, this.height);
// right, bottom 이라는 두 개의 계산된 프로퍼티 정의.
double get right => left + width;
set right(double value) => left = value - width;
double get bottom => top + height;
set bottom(double value) => top = value - height;
}
void main() {
var rect = Rectangle(3, 4, 20, 15);
print(rect.right); // 23.0
print(rect.left); // 3.0
rect.right = 20;
print(rect.right); // 20.0
print(rect.left); // 0.0
}
제 개인적인 생각에는 get은 가상의 멤버변수를 만들어서 이를 사용하게 해주고,
set은 이 가상의 멤버변수를 위해 처리해야 하는 기능을 함수 내부에서 별도로 처리하게 해준다가 주된 사용법인거 같습니다.
제가 가상
의 멤버변수라고 표현한 이유는, get으로 정의된 변수는 그 자체가 특정 값을 저장할 수 없고, 별도로 정의된 멤버변수에 의존해야 하기 때문입니다.
interface
그렇다면 get, set을 언제 사용해야 잘 사용한걸까요?
보통은 멤버변수를 은닉화
하고 이를 접근제어
를 할 때 사용하는 방식이 대부분일 것입니다.
하지만, 개인적으로 인터페이스
를 구현할 때 역시 get의 적절한 사용처라고 개인적으로 생각합니다.
예를 들어봅시다.
abstract class IColor {
String get color;
}
class Car implements IColor {
final String name;
final String color;
Car(this.name, this.color);
}
class Boat implements IColor {
final String name;
final String color;
Boat(this.name, this.color);
}
void main() {
Car car = Car('car1', 'red');
Boat boat = Boat('boat1', 'red');
print(car.color == boat.color); // true
}
dart에선 abstract class로 구현해도 될 정도로 interface의 의미가 약합니다.
최근에는 interface 키워드가 추가되었으나, implements 하는 것이 아닌 extends 해도 에러가 안 날정도로 중요하진 않은 거 같습니다.
abstract interface와 같이 키워드를 중복해서 쓰는 경우도 있으므로 자세한 내용은 공식문서
를 참고해주세요.
두 클래스간 필요에 따라
공통속성을 갖고싶게 할 때 interface를 사용하곤 하는데요,
dart는 null-safety
언어이기 떄문에, get 키워드가 없다면 abstract class라 하더라도 멤버변수를 초기화 해줘야만 합니다.
그러면 코드가 아래처럼 복잡해지겠죠.
abstract class IColor {
String color;
+ IColor(this.color);
}
class Car implements IColor {
final String name;
final String color;
Car(this.name, this.color);
+
+ set color(String _color) {
+ color = _color;
+ }
}
stream
비동기의 4번타자, future
는 완료되지 않은 연산을 나타냅니다.
비동기 함수가 return을 해야만 future은 연산이 완료되었다는 것을 알려줍니다.
그에 반면, stream
은 일련, iterable한 비동기 이벤트입니다.
따라서 iterable과 같이 동작하게 됩니다만, 그 과정이 비동기인 것이죠.
즉, future은 요청시 다음에 올 이벤트를 받는 방식이라면,
stream은 준비가 된 후 이벤트가 있음을 알려주는 방식입니다.
async* / yield
async*
는 async와 마찬가치로 이 함수가 비동기 함수라는 것임을 선언하는 키워드 입니다.
차이점은 이 함수는 Stream<T>
을 반환하기 때문에 일련의 비동기로 연산된 결과를 반환하게 됩니다.
즉, 지속적으로 값을 반환하기 위해 yield
키워드가 return을 대신하여 값을 반환합니다.
(return 키워드는 여기서 사용 불가합니다)
예시를 살펴봅시다.
Future<int> sumStream(Stream<int> stream) async {
var sum = 0;
await for (final value in stream) {
print('get stream value $value');
sum += value;
}
return sum;
}
Stream<int> countStream(int to) async* {
for (int i = 1; i <= to; i++) {
await Future.delayed(Duration(milliseconds: 200));
print('stream $i ready');
yield i;
}
}
void main() async {
var stream = countStream(3);
var sum = await sumStream(stream);
print(sum);
}
(0.2초 대기)
stream 1 ready
get stream value 1
(0.2초 대기)
stream 2 ready
get stream value 2
(0.2초 대기)
stream 3 ready
get stream value 3
6
Stream 역시 비동기로 처리되는 일련의 연산이라는 것을 알려주는 객체이기 때문에,
실제 연산 결과를 받으려면 await을 사용해서 값이 전달받을 때 까지 대기해야 합니다.
따라서, dart에서는 await for
키워드를 제공하여 iterable한 비동기 이벤트를 처리할 수 있도록 도와줍니다.
동기적은 Iterable<T>
객체를 반환하고 싶다면 sync*
/ yield
키워드를 사용하면 됩니다.
stream은 파일읽기와 같이 한 번의 요청이 있으면 처음부터 끝까지 끊김없이 진행되어야 하는 Single subscription streams
,
브라우저의 마우스 이벤트와 같이 Observer pattern을 사용하기에 언제든지 누구나 stream을 들을 수 있는 Broadcast streams
으로 나뉩니다.
뿐만 아니라, stream은 iterable한 성격을 갖는다 했었는데, 이런 stream을 제어할 수 있는 여러 메서드 또한 존재합니다.
따라서 자세한 내용은 공식문서
를 참고하시는걸 추천 드립니다.
mixin, with
간단하게 말하면 class → mixin
, extends → with
와 같은 개념입니다.
물론 이해를 돕기위해 위와같이 적었을 뿐, 완전히 다른 키워드이니 혼동하지 마시길 바랍니다.
클래스를 상속할 때는 부모 클래스에서 미리 정의된 변수나 메서드를 자식 클래스에서 구현하지 않아도 바로 사용할 수 있습니다.
하지만, 계층구조를 나타내기 위해 반드시 한 개의 클래스를 상속해야 한다는 단점이 있죠.
여러개의 자료 구조를 공유하기 위해서는 인터페이스를 구현 하는 방법으로 사용해야 합니다.
하지만, 인터페이스는 말 그대로 틀만 제공하고 구현은 별도로 해야한다는 단점이 있습니다.
이 떄, extends와 implements의 중간 포지션에 위치한 키워드가 with이고, with은 mixin을 받을 수 있습니다.
mixin SwimBehabior {
void swim() {
print('swim');
}
}
mixin FlyBehiabior {
void fly() {
print('fly');
}
}
class Duck {
final String name;
Duck(this.name);
}
class RealDuck extends Duck with SwimBehabior, FlyBehiabior {
RealDuck(super.name);
}
class RubberDuck extends Duck with SwimBehabior {
RubberDuck(super.name);
}
void main() {
RealDuck duck = RealDuck('duck');
duck.fly();
duck.swim();
}
이런식으로 implements 처럼 여러 인터페이스를 사용할 수 있지만, extends 처럼 재정의할 필요 없이 사용할 수 있습니다.
하지만, mixin을 사용할 때는 클래스 구성요소가 매우 세분화
되어있는게 좋습니다.
이런저런 메서드를 잔뜩 만들어놓으면 상속했을 때 필요없는 기능도 수행할 수 있는 상황이 발생할 수 있기 때문입니다.
이런 상황이 생길 가능성이 있는 코드는 mixin 보단 Strategy pattern
으로 구현하는 것이 더 좋습니다.
abstract class SwimBehavior {
void swim();
}
abstract class FlyBehavior {
void fly();
}
class CanSwim implements SwimBehavior {
void swim() {
print('swim');
}
}
class Duck {
final String name;
SwimBehavior swimBehavior;
Duck(this.name, this.swimBehavior);
void performSwim() {
swimBehavior.swim();
}
}
class RealDuck extends Duck implements FlyBehavior {
RealDuck(String name) : super(name, CanSwim());
void fly() {
print('fly');
}
}
class RubberDuck extends Duck {
RubberDuck(String name) : super(name, CanSwim());
}
void main() {
RealDuck real = RealDuck('duck');
RubberDuck rubber = RubberDuck('duck');
real.performSwim();
real.fly();
rubber.performSwim();
}
사실 mixin을 사용하지 않더라도 class의 상속 depth를 깊게
하는 방법도 있으니, 본인의 상황에 맞게 취사선택을 잘 하는 것이 좋은 개발습관이 될 것입니다.