클래스: 객체지향의 기초

클래스: 객체지향의 기초

Dart는 클래스와 믹스인 기반 상속을 쓰는 객체지향 언어예요. 모든 객체는 어떤 클래스의 인스턴스이고, Null을 제외한 모든 클래스는 Object에서 내려옵니다. 믹스인 기반 상속이란, 모든 클래스가 (최상위 클래스인 Object?를 빼고) 정확히 하나의 슈퍼클래스를 가지면서도, 클래스 본문은 여러 클래스 계층에서 재사용할 수 있다는 뜻이에요. 이 글에서는 클래스 멤버 사용법, 생성자, 인스턴스 변수, 암시적 인터페이스, static 멤버까지 차례대로 살펴볼게요.

출처: Dart 공식 문서 — Classes

클래스 멤버 사용하기

객체에는 함수와 데이터로 이루어진 멤버(각각 메서드인스턴스 변수)가 있어요. 메서드를 호출하면 그 객체에 대해 호출하게 되고, 메서드는 그 객체의 함수와 데이터에 접근할 수 있어요.

인스턴스 변수나 메서드를 참조할 때는 점(.)을 씁니다.

var p = Point(2, 2);

// Get the value of y.
assert(p.y == 2);

// Invoke distanceTo() on p.
double distance = p.distanceTo(Point(4, 4));

가장 왼쪽 피연산자가 null일 때 예외를 피하려면 . 대신 ?.를 써요.

// If p is non-null, set a variable equal to its y value.
var a = p?.y;

생성자 사용하기

생성자로 객체를 만들 수 있어요. 생성자 이름은 ClassName이거나 ClassName.identifier 형태예요. 다음 코드는 Point()Point.fromJson() 생성자로 Point 객체를 만듭니다.

var p1 = Point(2, 2);
var p2 = Point.fromJson({'x': 1, 'y': 2});

다음 코드는 같은 효과지만 생성자 이름 앞에 선택적인 new 키워드를 사용해요.

var p1 = new Point(2, 2);
var p2 = new Point.fromJson({'x': 1, 'y': 2});

어떤 클래스는 [상수 생성자]를 제공해요. 상수 생성자로 컴파일 타임 상수를 만들려면 생성자 이름 앞에 const 키워드를 붙입니다.

var p = const ImmutablePoint(2, 2);

두 개의 동일한 컴파일 타임 상수를 만들면 단일 정규(canonical) 인스턴스가 됩니다.

var a = const ImmutablePoint(1, 1);
var b = const ImmutablePoint(1, 1);

assert(identical(a, b)); // They are the same instance!

상수 컨텍스트(constant context) 안에서는 생성자나 리터럴 앞의 const를 생략할 수 있어요. 예를 들어 const 맵을 만드는 다음 코드를 보겠습니다.

// Lots of const keywords here.
const pointAndLine = const {
  'point': const [const ImmutablePoint(0, 0)],
  'line': const [const ImmutablePoint(1, 10), const ImmutablePoint(-2, 11)],
};

const의 첫 사용 이후로는 모두 생략할 수 있어요.

// Only one const, which establishes the constant context.
const pointAndLine = {
  'point': [ImmutablePoint(0, 0)],
  'line': [ImmutablePoint(1, 10), ImmutablePoint(-2, 11)],
};

상수 생성자가 상수 컨텍스트 바깥에서 const 없이 호출되면 비상수 객체를 만듭니다.

var a = const ImmutablePoint(1, 1); // Creates a constant
var b = ImmutablePoint(1, 1); // Does NOT create a constant

assert(!identical(a, b)); // NOT the same instance!

객체의 타입 알아내기

런타임에 객체의 타입을 얻으려면 Object 속성인 runtimeType을 써요. 이것은 Type 객체를 반환합니다.

print('The type of a is ${a.runtimeType}');

여기까지는 클래스를 사용하는 방법이었어요. 나머지 부분에서는 클래스를 구현하는 방법을 보여줄게요.

인스턴스 변수

인스턴스 변수 선언은 이렇게 해요.

class Point {
  double? x; // Declare instance variable x, initially null.
  double? y; // Declare y, initially null.
  double z = 0; // Declare z, initially 0.
}

[null 허용 타입]으로 선언한 초기화되지 않은 인스턴스 변수는 null 값을 가져요. null 허용이 아닌 인스턴스 변수는 선언할 때 초기화되어야 해요.

모든 인스턴스 변수는 암시적 getter 메서드를 만듭니다. final이 아닌 인스턴스 변수와 초기화되지 않은 late final 인스턴스 변수도 암시적 setter 메서드를 만듭니다.

class Point {
  double? x; // Declare instance variable x, initially null.
  double? y; // Declare y, initially null.
}

void main() {
  var point = Point();
  point.x = 4; // Use the setter method for x.
  assert(point.x == 4); // Use the getter method for x.
  assert(point.y == null); // Values default to null.
}

late가 아닌 인스턴스 변수를 선언 지점에서 초기화하면, 그 값은 생성자와 초기화 목록이 실행되기 전에 인스턴스가 만들어질 때 설정돼요. 그 결과, late가 아닌 인스턴스 변수의 초기화 표현식(=뒤)은this`에 접근할 수 없어요.

double initialX = 1.5;

class Point {
  // OK, can access declarations that do not depend on `this`:
  double? x = initialX;

  // ERROR, can't access `this` in non-`late` initializer:
  double? y = this.x;

  // OK, can access `this` in `late` initializer:
  late double? z = this.x;

  // OK, `this.x` and `this.y` are parameter declarations, not expressions:
  Point(this.x, this.y);
}

인스턴스 변수는 final일 수 있는데, 이 경우 정확히 한 번만 설정되어야 해요. final, late가 아닌 인스턴스 변수는 선언 지점, 생성자 파라미터, 또는 생성자의 [초기화 목록]에서 초기화합니다.

class ProfileMark {
  final String name;
  final DateTime start = DateTime.now();

  ProfileMark(this.name);
  ProfileMark.unnamed() : name = '';
}

생성자 본문이 시작된 뒤에 final 인스턴스 변수의 값을 할당해야 한다면 다음 중 하나를 써요.

  • [팩토리 생성자]에서 값을 계산해, final 인스턴스 변수를 초기화하는 generative 생성자에 전달한다.
  • late final을 쓰되, 초기화되지 않은 late final은 API에 setter를 추가한다는 점을 조심한다.

암시적 인터페이스

모든 클래스는 암시적으로, 클래스의 모든 인스턴스 멤버와 그 클래스가 implements하는 모든 인터페이스의 멤버를 포함하는 인터페이스를 정의해요. B의 구현을 상속하지 않고 B의 API를 지원하는 클래스 A를 만들고 싶다면, A는 B 인터페이스를 implements하면 됩니다.

클래스는 implements 절에 인터페이스를 선언하고 인터페이스가 요구하는 API를 제공해 하나 이상의 인터페이스를 구현해요. 예를 들면 다음과 같아요.

// A person. The implicit interface contains greet().
class Person {
  // In the interface, but visible only in this library.
  final String _name;

  // Not in the interface, since this is a constructor.
  Person(this._name);

  // In the interface.
  String greet(String who) => 'Hello, $who. I am $_name.';
}

// An implementation of the Person interface.
class Impostor implements Person {
  String get _name => '';

  String greet(String who) => 'Hi $who. Do you know who I am?';
}

String greetBob(Person person) => person.greet('Bob');

void main() {
  print(greetBob(Person('Kathy')));
  print(greetBob(Impostor()));
}

클래스가 여러 인터페이스를 구현하도록 지정하는 예시는 다음과 같아요.

class Point implements Comparable, Location {
  ...
}

클래스 변수와 메서드

클래스 전역 변수와 메서드를 구현하려면 static 키워드를 써요.

정적 변수

정적 변수(클래스 변수)는 클래스 전역 상태와 상수에 유용해요.

class Queue {
  static const initialCapacity = 16;
  // ···
}

void main() {
  assert(Queue.initialCapacity == 16);
}

정적 변수는 사용되기 전까지 초기화되지 않아요.

정적 메서드

정적 메서드(클래스 메서드)는 인스턴스에 대해 동작하지 않으므로 this에 접근할 수 없어요. 하지만 정적 변수에는 접근할 수 있습니다. 다음 예시처럼 정적 메서드는 클래스에 직접 호출해요.

import 'dart:math';

class Point {
  double x, y;
  Point(this.x, this.y);

  static double distanceBetween(Point a, Point b) {
    var dx = a.x - b.x;
    var dy = a.y - b.y;
    return sqrt(dx * dx + dy * dy);
  }
}

void main() {
  var a = Point(2, 2);
  var b = Point(4, 4);
  var distance = Point.distanceBetween(a, b);
  assert(2.8 < distance && distance < 2.9);
  print(distance);
}

정적 메서드는 컴파일 타임 상수로 쓸 수 있어요. 예를 들어 정적 메서드를 상수 생성자의 파라미터로 전달할 수 있습니다.

더 알아보기