Dart는 객체 지향 언어이며 동시에 믹스인 기반 상속(Mixin based inheritance)을 적용한 언어이다.
Dart는 객체 지향 언어이기 때문에, 모든 객체는 특정 클래스의 인스턴스이고 Null
을 제외한 모든 클래스는 Object
클래스로부터 상속된다. 그건 뭐 전에도 계속 언급했었으니 그렇다 치고, 그럼 믹스인 기반 상속이 대체 뭘까? 개인적으로는 처음 듣는 개념이라 구글링을 조금 해 보았다.
객체 지향 프로그래밍 언어에서 믹스인(mixin 또는 mix-in)은 다른 클래스의 부모클래스가 되지 않으면서 다른 클래스에서 사용할 수 있는 메소드를 포함하는 클래스입니다. 다른 클래스가 믹스인의 메소드에 액세스하는 방법은 언어에 따라 다릅니다. 믹스인은 때때로 "상속"이 아니라 "포함"으로 설명됩니다.
- 위키백과, 믹스인에서 발췌
말이 어려운데, 쉽게 말해 여러 클래스에서 공통적으로 활용해야 하는 메소드가 있을 때, 이 메소드들을 정의해 둔 클래스를 의미하는 것 같다. 마치 인공위성의 모듈처럼 클래스에 떼었다 붙였다 하는 느낌이라서, "상속"이 아닌 "포함"으로 설명된다고 덧붙인 것으로 생각된다. 또한 JS에서 자주 사용하는 개념인 듯하다.
이에 대해서는 아래의 '믹스인(Mixins)' 파트에서 확장 메소드와 함께 설명하도록 하겠다.
다음 코드에서 볼 수 있듯이, 두 가지 방법이 있다:
// 1. Object 클래스에서 상속된 runtimeType() 함수 활용
someObject.runtimeType()
// 2. is 연산자로 직접 비교
print(someObject is int ? true : false)
다만 1번 방법은 runtimeType().toString()
의 형태로 써 주어야 문자열로 활용할 수 있고, Dart 공식 문서는 1번에 비해 더 안정적인 2번 방법을 쓸 것을 추천하고 있다. 참고하기 바란다.
static
키워드를 활용하여 클래스 수준의 변수와 상수를 정의할 수 있다.
this
키워드도 사용할 수 없음, 그러나 전역 변수에 대한 접근 권한이 있음// 전역 변수 예시
class Kayle {
static const initialRange = 175;
}
// 전역 메소드 예시
class Point {
double x, y;
static double distanceBetween(Point a, Point b) {
// TODO
}
}
Abstract class로도 잘 알려진 그것이다. abstract
지정자를 통해 생성할 수 있으며, 추상 클래스로는 객체를 생성할 수 없다. 추상 클래스는 인터페이스를 정의할 때 유용한 도구가 될 것이다.
abstract class ClassName {
void func(); // 추상 메소드임
}
class UsefulClassName extends ClassName {
void func() {
// TODO
}
}
추상 클래스는 추상 메소드를 포함할 수 있으며, 추상 메소드에 대해서는 아래의 '객체 메소드' 파트에서 설명하도록 하겠다.
만약 자신이 속한 클래스의 객체를 생성할 수 없는 생성자를 구현하고 싶다면, factory
생성자를 정의하면 된다. 근데 이 factory
생성자, 상당히 복잡하다... 그래서 아래의 '생성자' 파트에서 별도로 다루도록 하겠다.
(Implement에 해당하는 까리한 국문 단어를 찾고 싶었으나, '구현'이라고 쓰기에는 뭔가 이상해서 그냥 영어 원문으로 쓴다...)
모든 클래스는 그 클래스의 객체 변수, 그리고 그 클래스가 implement하는 다른 클래스의 모든 객체 변수를 암시적으로 정의한다. 만약 사용자가 클래스 B의 API를 지원하는 클래스 A를 생성하고 싶다면, 클래스 A는 클래스 B의 인터페이스를 implement해야 한다.
클래스는 하나 이상의 다른 클래스를 implement
문에 명시해 주고, 그 인터페이스들이 필요로 하는 API들을 구현함으로써 implement할 수 있다. 아래 코드처럼 말이다:
// implement할 클래스
class Person {
final String _name;
Person(this._name);
String greet(String who) => "안녕, $who? 나는 $_name이라고 해.";
}
// Person 클래스를 implement하여 구현한 클래스
class AnonymousPerson implements Person {
String get _name => "";
String greet(String who) => "안녕, $who. 내가 누군지 맞춰 볼래?";
}
extends
문을 사용하여 부모 클래스를 상속하는 자식 클래스를 생성할 수 있다. 그리고 super
키워드를 통해 부모 클래스에 접근할 수 있다.
class Television {
void turnOn() {
_illuminateDisplay();
_activateIrSensor();
}
// ···
}
class SmartTelevision extends Television {
void turnOn() {
super.turnOn();
_bootNetworkInterface();
_initializeMemory();
_upgradeApps();
}
// ···
}
자식 클래스는 부모 클래스의 객체 메소드, 연산자, Getter와 Setter를 오버라이딩할 수 있다. 오버라이딩를 위해서는 선언할 메소드 윗 줄에 @override
를 적어주어 사용자가 의도적으로 메소드를 오버라이딩했다는 사실을 명시할 수 있다.
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
// TODO
}
}
다만 메소드를 오버라이딩할 때 지켜야 할 몇 가지 규칙이 있다. 아래를 참고하도록 하자:
Object
가 원본이었으면 int
도 가능) int
가 원본이었으면 num
도 가능)여기서 두 번째 항목을 다시 한 번 보자. "오버라이딩 메소드의 매개 변수 자료형은 원본 메소드의 것과 같거나 그것의 상위 자료형이여야 한다." 다만, 아주 드물겠지만 오버라이딩 메소드의 매개 변수 자료형을 원본 메소드의 하위 자료형으로 선언해야 할 때가 있을 수 있다. 이럴 때에는 그 매개 변수에 covariant
키워드를 적용하면 된다. 사용 예시는 아래와 같다:
class Animal {
void chase(Animal x) { ... }
}
class Mouse extends Animal { ... }
ㅣㅣ
class Cat extends Animal {
void chase(covariant Mouse x) { ... }
}
noSuchMethod()
의 오버로드만약 코드에서 존재하지 않는 객체 메소드나 변수에 접근할 때의 대응 방식을 직접 설정하고 싶다면, noSuchMethod()
메소드를 오버라이드하면 된다. 아래 코드를 참고하자:
class A {
void noSuchMethod(Invocation invocation) {
print('You tried to use a non-existent member: '
'${invocation.memberName}');
}
}
모두가 익히 알고 있을 그 방법이다.
class Human {
String name;
int? age;
}
초기화되지 않은 변수에는 null
이 할당된다. 또한 모든 객체 변수는 암시적으로 Getter 메소드를 생성한다. 추가로 final
키워드가 없거나 late final
키워드가 붙은 변수는, 암시적으로 Setter 메소드를 생성한다.
Getter와 Setter는 메소드에 해당하므로, 아래의 '객체 메소드' 파트에서 알아보도록 하자.
모두가 익히 알고 있을 그 접근법이다. .
을 활용하면 되며, 이러한 접근은 객체 변수 뿐만 아니라 함수에도 사용할 수 있다. (예시에서는 생성자는 생략하겠다.)
class Point {
int x;
int y;
void printPoint() => print("$x, $y");
}
Point p = Point(10, 5);
print(p.x);
추가로 만약 접근할 객체에 나도 모르는 사이 null
값 흘러들어갈 것 같은 느낌이 든다면 ?.
를 대신 사용하면 된다. ?.
는 아래와 같이 동작한다:
null
이 아니라면: ?.
우측의 코드가 정상 동작null
이라면: ?.
우측의 코드가 동작하지 않으며 null
을 반환print(p?.printPoint())
위 코드에서 만약 점 p
가 null
이라면 printPoint()
는 실행되지 않는다.
final
키워드객체 변수는 final
로 선언할 수 있다. 사용자는 final
변수 혹은 late
키워드가 없는 일반 변수를 선언 시 초기화해야 하는데, 이 때 생성자나 생성자의 초기화 리스트를 활용할 수 있다. 이 두 가지 요소는 아래 '생성자' 파트에서 소개하도록 하겠다.
class Oven {
void setTemparature() {
// TODO
}
}
객체 메소드는 위와 같이 선언할 수 있다. 참 쉽죠?
Dart는 특정 클래스에 대해 연산자가 할 동작을 정의할 수 있도록 해 준다. 예를 들어 특정 클래스 간 사칙 연산(+, -, *, /)같은 것들 말이다. 아래는 Dart에서 정의할 수 있도록 허용하는 연산자의 목록이다:
연산자 오버로드의 예시는 아래 코드를 참고하도록 하자:
class Vector {
final int x, y;
Vector(this.x, this.y);
Vector operator +(Vector v) => Vector(x + v.x, y + v.y);
Vector operator -(Vector v) => Vector(x - v.x, y - v.y);
}
Getter와 Setter는 또 뭘까? 얘네는 Object.get()
이나 Object.set(val)
처럼 생긴 친구들이다. 즉 객체 변수를 읽거나 수정할 수 있는 특별한 메소드들을 말하는 것이다. 바로 위의 '객체 변수' 파트에서 설명했듯이 모든 객체 변수는 암시적으로 Getter(그리고 값을 수정할 수 있는 변수라면 Setter까지)를 가진다.
사용자는 필요한 경우 get
과 set
키워드로 별도의 Getter와 Setter를 정의할 수 있다. 아래와 같이:
class Rectangle {
double left, top, width, height;
Rectangle(this.left, this.top, this.width, this.height);
double get right => left + width;
double get bottom => top + height;
set right(double value) => left = value - width;
set bottom(double value) => top = value - height;
}
Abstract methods. 세부 동작이 구현되지 않은 뼈대만 있는 메소드이다. 인터페이스만 정의해 두고 그 구현은 다른 클래스에 맡기는 구조. 객체 메소드와 Getter, Setter 메소드들은 모두 추상 메소드가 될 수 있다.
추상 메소드는 위에서 언급한 추상 클래스에서만 선언될 수 있다. 또한 당연히 그 내용이 여기서는 구현이 안 되어 있으므로, 추상 메소드를 상속받는 자식 메소드에서 이에 대한 구현을 해 주어야 한다.
형태는 아래와 같다:
abstract class Car {
void startEngine();
}
사용자는 아래 코드처럼 생성자로 객체를 생성할 수 있다. 생성자의 이름은 클래스 이름
또는 클래스 이름.식별자
가 될 수 있다.
var p1 = Point(1, 3);
var p2 = Point.doublePoint(2.4, 3.14);
또한 생성자를 선언함에 있어 this
키워드를 사용할 수 있다. this
키워드는 이 생성자를 활용할 객체를 의미한다. 아마 다른 언어에서도 익숙하게 보던 것들이니 크게 어렵지는 않을 것이라고 생각한다.
class Point {
int x;
int y;
// 일반적인 생성자
Point(this.x, this.y);
// 식별자(이름)를 갖는 생성자
Point.namedConstructer(this.x, this.y);
// 명명된 매개 변수를 활용한 생성자
Point.namedParameter(this.x, {this.y});
}
생성자는 클래스 이름
과 똑같은 함수를 생성하여 정의할 수 있다. 만약 별도로 이름을 지정하고 싶은 생성자가 있을 경우, 클래스 이름 뒤에 클래스 이름.식별자
와 같이 식별자를 적어주면 된다. 또한 생성자를 선언할 때 매개 변수에는 선택적 매개 변수, 명명된 매개 변수도 활용할 수 있다.
기본 생성자
만약 사용자가 별도의 생성자를 정의하지 않는다면, '기본 생성자'가 암시적으로 제공된다. 이 생성자는 이름도, 매개 변수도 갖고 있지 않다.
생성자는 상속되지 않음
자식 클래스는 부모 클래스로부터 생성자를 상속받지 않는다. 생성자를 하나도 정의하지 않은 자식 클래스는 기본 생성자만 가지게 된다.
const
생성자만약 클래스가 불변하는 객체를 생성한다면, 사용자는 이 객체들을 컴파일 상수로 지정해버릴 수 있다. 이를 위해서는 다음 두 가지 조건이 충족되어야 한다:
const
키워드로 선언할 것final
로 선언할 것class ImmutablePoint {
final double x, y; // 클래스 변수를 final로 선언
const ImmutablePoint(this.x, this.y); // 생성자를 const로 선언
}
const
생성자로 컴파일 상수를 생성하기 위해서는 생성자 바로 왼쪽에 const
키워드를 붙여주면 된다. 그러나 만약 const
생성자가 상수로 지정해 둔 범위를 벗어나거나 const
키워드 없이 선언되었을 경우, 그 생성자는 상수가 아닌 객체를 생성한다. 꼭 기억해두자.
var a = const ImmutablePoint(1, 1); // 상수 생성
var b = ImmutablePoint(2, 2); // 상수 생성하지 않음
final
변수와 late
키워드가 없는 일반적인 변수는 반드시 선언 시 초기화를 해 주어야 한다. 이 때 사용자는 변수의 초기화에 생성자 또는 초기화 리스트를 활용할 수 있다. 아마 C++을 조금 만져 본 분에게는 익숙하게 느껴질 것이다. 코드는 아래와 같다:
class Point {
final double x;
final double y;
final double distanceFromOrigin;
Point(double x, double y)
: x = x,
y = y,
distanceFromOrigin = sqrt(x * x + y * y);
}
factory
생성자이게 이번 글 쓰면서 제일 어려웠던 내용 같다. 처음 듣는 여러 개념들이 짬뽕처럼 섞여 있었기 때문이다. 어쨌든 이를 설명하기 위해서 우리는 잠시 처음으로 돌아가 생성자의 기초를 확인할 필요가 있다.
Dart의 생성자는 크게 두 종류로 나뉜다. 첫 번째는 Generative 생성자로 항상 새로운 객체를 생성하기 때문에 Generative라는 표현이 붙은 것으로 보인다. 그리고 남은 하나가 바로 Factory 생성자인 것이다.
Dart 공식 문서에서는 Factory 생성자를 이렇게 설명하고 있다:
Use the
factory
keyword when implementing a constructor that doesn’t always create a new instance of its class.
(Factory 생성자가 정의된) 클래스의 새 인스턴스를 생성하지 않는 생성자를 구현하고 싶을 때factory
키워드를 사용해보세요.
- Factory constructors | Language tour | Dart
여기서 중점적으로 봐야 할 부분은 클래스의 새 인스턴스를 생성하지 않는 생성자라고 생각한다. 이 문장을 통해 뽑아낼 수 있는 결론은:
final
변수를 초기화할 때 사용할 수 있음첫 번째 항목은 팩토리 패턴(Factory pattern)이라는 개념을 의미한다고 한다.
팩토리 메서드 패턴(Factory method pattern)은 (중략) 자식(하위) 클래스가 어떤 객체를 생성할지를 결정하도록 하는 패턴이기도 하다.
- 위키백과, 팩토리 메서드 패턴
나처럼 이걸 처음 보는 분들은, 팩토리 패턴이나 팩토리 메서드 패턴이나 같은 얘기 같으니 그냥 그렇구나 하고 넘어가시면 된다. 어쨌든 중요한 점은 자식 클래스의 인스턴스를 상위 클래스에서 생성한다는 거다.
이 점을 바탕으로 추측을 해 보자면, 과자 공장에서 허니버터칩 과자, 맛동산 과자 등 다양한 종류의 하위 과자들을 찍어내듯이 상위 클래스에서 상위 클래스의 특성을 상속받는 하위 클래스를 찍어내기 때문에 공장(Factory)이라는 이름이 붙지 않았나 싶다.
팩토리 패턴을 염두하고 사용한 Factory 생성자의 예시는 아래 코드를 참고하자. 이거 여기서 실제로 돌려볼 수도 있다:
import 'dart:math';
abstract class Shape {
factory Shape(String type) {
if (type == 'circle') return Circle(2);
if (type == 'square') return Square(2);
// To trigger exception, don't implement a check for 'triangle'.
throw 'Can\'t create $type.';
}
num get area;
}
class Circle implements Shape {
final num radius;
Circle(this.radius);
num get area => pi * pow(radius, 2);
}
class Square implements Shape {
final num side;
Square(this.side);
num get area => pow(side, 2);
}
class Triangle implements Shape {
final num side;
Triangle(this.side);
num get area => pow(side, 2) / 2;
}
main() {
try {
print(Shape('circle').area);
print(Shape('square').area);
print(Shape('triangle').area);
} catch (err) {
print(err);
}
}
이상의 코드는 Understanding Factory constructor code example - Dart - StackOverflow에서 발췌하였음을 알린다.
(사실 직접 구현해보고 싶었지만 너무 졸려서 대충 날로 먹고 꿀잠자러 가고 싶은 욕구가 더 컸음...)
두 번째 항목은 싱글톤 패턴(Singleton pattern)이라는 개념을 의미한다고 한다. 왜 자꾸 Dart 게시글에 디자인 패턴이 등장하는지 전혀 모르겠지만, 어쨌든 나는 싱글톤 패턴을 처음 들어보기 때문에 위키백과에서 관련 내용을 긁어와보았다:
소프트웨어 디자인 패턴에서 싱글턴 패턴(Singleton pattern)을 따르는 클래스는, 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다.
(중략) 파이썬의 모듈은 그 자체로 싱글턴이다.
- 위키백과, 싱글턴 패턴
싱글톤 패턴을 쓰는 이유까지 설명하기에는 이 글의 목적을 너무 벗어난 것 같다. 그래서 내가 참고한 레퍼런스를 남겨놓는 선에서 정리하도록 하겠다:
어쨌든 다시 돌아와서, Generative 생성자는 기존에 있던 특정 개체를 반환하지 않기 때문에 return
문을 사용하지 않지만, Factory 생성자는 생성자 안에서 반환할 객체를 생성하고 이를 return
문으로 넘겨주어야 하는 듯하다. Dart 공식 문서의 예제를 참고하자:
class Logger {
final String name;
bool mute = false;
// _cache is library-private, thanks to
// the _ in front of its name.
static final Map<String, Logger> _cache = <String, Logger>{};
factory Logger(String name) {
return _cache.putIfAbsent(name, () => Logger._internal(name));
}
factory Logger.fromJson(Map<String, Object> json) {
return Logger(json['name'].toString());
}
Logger._internal(this.name);
void log(String msg) {
if (!mute) print(msg);
}
}
코드를 보면, putIfAbsent()
가 정확이 어떻게 정의되어 있는지는 알 수 없지만 함수 이름을 바탕으로 동작을 추측해보았을 때, name
에 해당하는 Logger가 없을 경우 name
에 해당하는 Logger를 새로 만들어 반환하는 것 같다.
믹스인(Mixins)은 클래스의 코드를 다수의 클래스 계층 구조에서 재사용하는 방법이다.
믹스인을 사용하기 위해서는, 클래스를 선언할 때 하나 이상의 사용할 믹스인을 with
키워드와 함께 적어주면 된다. 아래의 코드는 믹스인을 사용하는 두 가지 클래스의 예시를 보여준다:
class Zery extends Champion with ADCarry {
// TODO
}
class Vayne extends Champion with ADCarry, TopLaner {
// TODO
}
믹스인을 구현하기 위해서는 다음 두 가지 방법 중 하나를 활용하면 된다:
Object
를 상속(extends)하며 생성자가 없는 클래스를 선언class
대신 mixin
키워드를 사용두 번째 방법에 대한 예시 코드는 아래와 같다:
mixin ADCarry {
bool canAttackFromFarAway = true;
void playADCarry() {
if (canAttackFromFarAway) {
print("원거리에서 기본 공격을 날립니다.");
}}
}
어쩌면 사용자는 믹스인을 사용할 수 있는 자료형 또는 클래스를 제한하고 싶을 수도 있다. 그럴 경우 on
키워드를 함께 사용하여 믹스인을 사용할 수 있는 자료형 또는 클래스를 특정할 수 있다. 아래 코드처럼:
class Musician {
// ...
}
mixin MusicalPerformer on Musician {
// ...
}
class SingerDancer extends Musician with MusicalPerformer {
// ...
}
Dart에는 열거형(Enumeration 또는 enums)이라고 불리는 자료형도 있다. 얘를 전혀 상관 없어 보이는 클래스 문서에서 소개하는 이유는, Dart 공식 문서에서 열거형을 '고정된 값을 갖는 상수를 나타내기 위해 사용하는 특별한 종류의 클래스'라고 정의하고 있기 때문이다.
뭐, 어쨌든 평범한 열거형을 선언하는 방법은 아래와 같다:
enum Color { red, green, blue }
열거형의 각 상수에는 다른 static
변수처럼 접근할 수 있다.
print(Color.red);
또한 각 상수에는 index
Getter가 존재하며, 이 Getter 메소드는 열거형을 선언한 순서대로 처음 상수부터 0을 index
로 매긴다. 마치 C 언어의 배열처럼 말이다. 예를 들어 첫 번째 상수는 0의 값을 가지며, 두 번째 상수는 1의 값을 가지고, ..., 이런 식이다.
print(Color.red.index); // 0 출력됨
열거형의 모든 상수 값을 포함한 리스트를 얻고 싶다면, enum.values
를 활용하자.
List<Color> colors = Color.values;
print(colors[0]); // "Colors.red" 출력됨
만약 열거형 상수의 이름을 문자열로 받고 싶다면 enum.constant.name
을 활용하자.
print(Color.red.name) // "red" 출력됨
Dart는 클래스 수준 변수, 메소드, const
생성자를 함께 활용할 수 있는 '풍부한 열거형'의 선언을 지원한다. 풍부한 열거형을 선언하기 위해서는 평범한 클래스 선언과 유사하게 코드를 작성하면 되지만, 다음 몇 가지 추가적인 요구 사항을 충족해야 한다:
final
이어야 한다.factory
연산자는 오직 하나의, 이미 알려진 열거형 객체만을 반환할 수 있다.index
, hashCode
, 비교 연산자 ==
에 대한 오버라이딩은 불가능하다.values
라는 식별자로 열거형을 선언할 수 없다. 자동으로 생성되는 values
Getter와 충돌하기 때문이다.아래는 Dart 공식 문서에서 제공하는 풍부한 열거형의 예시이다:
enum Vehicle implements Comparable<Vehicle> {
car(tires: 4, passengers: 5, carbonPerKilometer: 400),
bus(tires: 6, passengers: 50, carbonPerKilometer: 800),
bicycle(tires: 2, passengers: 1, carbonPerKilometer: 0);
const Vehicle({
required this.tires,
required this.passengers,
required this.carbonPerKilometer,
});
final int tires;
final int passengers;
final int carbonPerKilometer;
int get carbonFootprint => (carbonPerKilometer / passengers).round();
int compareTo(Vehicle other) => carbonFootprint - other.carbonFootprint;
}