learn-flutter/content/docs/part2/classes.md
ChangJoo Park(박창주) 900b0ab68b update markdown
2025-05-14 09:34:13 +09:00

16 KiB

클래스, 생성자, 팩토리

클래스 기본

Dart는 완전한 객체 지향 언어로, 클래스와 객체 개념을 중심으로 설계되었습니다. Dart에서 모든 것이 객체이며, 모든 객체는 클래스의 인스턴스입니다.

클래스 정의하기

class Person {
  // 필드(속성)
  String name;
  int age;

  // 생성자
  Person(this.name, this.age);

  // 메서드
  void introduce() {
    print('안녕하세요, 저는 $name이고 $age살입니다.');
  }

  // 게터(Getter)
  bool get isAdult => age >= 18;

  // 세터(Setter)
  set setAge(int newAge) {
    if (newAge >= 0) {
      age = newAge;
    }
  }
}

// 클래스 사용 예시
void main() {
  final person = Person('홍길동', 30);
  person.introduce();  // 안녕하세요, 저는 홍길동이고 30살입니다.

  print('성인 여부: ${person.isAdult}');  // 성인 여부: true

  person.setAge = 25;
  print('변경된 나이: ${person.age}');  // 변경된 나이: 25
}

private 멤버

Dart에서는 식별자 앞에 밑줄(_)을 붙여 라이브러리 수준에서 private 멤버를 정의합니다. private 멤버는 클래스가 정의된 파일 외부에서 접근할 수 없습니다.

// person.dart 파일
class Person {
  String name;
  int _age;  // private 필드

  Person(this.name, this._age);

  void introduce() {
    print('안녕하세요, 저는 $name이고 $_age살입니다.');
  }

  int get age => _age;  // private 필드에 접근하는 public 게터

  void _privateMethod() {  // private 메서드
    print('이 메서드는 클래스 내부에서만 호출할 수 있습니다.');
  }

  void publicMethod() {
    _privateMethod();  // private 메서드 호출
  }
}

// main.dart 파일
import 'person.dart';

void main() {
  final person = Person('홍길동', 30);
  print(person.name);  // 홍길동
  print(person.age);   // 30

  // 다음 코드는 컴파일 오류 발생
  // print(person._age);           // 오류: '_age' is not defined
  // person._privateMethod();      // 오류: '_privateMethod' is not defined

  person.publicMethod();  // OK: 클래스 내부에서 private 메서드 호출
}

생성자

Dart에서는 다양한 방식으로 생성자를 정의할 수 있습니다.

기본 생성자

class Person {
  String name;
  int age;

  // 기본 생성자
  Person(this.name, this.age);
}

이름이 있는 생성자

한 클래스에 여러 생성자를 정의하고 싶을 때 이름이 있는 생성자를 사용합니다:

class Person {
  String name;
  int age;

  // 기본 생성자
  Person(this.name, this.age);

  // 이름이 있는 생성자
  Person.guest() {
    name = '손님';
    age = 0;
  }

  Person.child(this.name) {
    age = 10;
  }

  Person.adult(this.name) {
    age = 18;
  }
}

void main() {
  final person1 = Person('홍길동', 30);
  final person2 = Person.guest();
  final person3 = Person.child('아이');
  final person4 = Person.adult('성인');

  print('${person1.name}, ${person1.age}');  // 홍길동, 30
  print('${person2.name}, ${person2.age}');  // 손님, 0
  print('${person3.name}, ${person3.age}');  // 아이, 10
  print('${person4.name}, ${person4.age}');  // 성인, 18
}

초기화 리스트

생성자 본문이 실행되기 전에 인스턴스 변수를 초기화해야 할 때 초기화 리스트를 사용합니다:

class Person {
  String name;
  int age;
  final DateTime birthDate;

  // 초기화 리스트 사용
  Person(this.name, this.age)
      : birthDate = DateTime.now().subtract(Duration(days: 365 * age));

  // 여러 필드 초기화
  Person.custom(String userName, int userAge)
      : name = userName.toUpperCase(),
        age = userAge > 0 ? userAge : 0,
        birthDate = DateTime.now().subtract(Duration(days: 365 * userAge));
}

상수 생성자

인스턴스가 변경 불가능한 객체일 때 상수 생성자를 사용합니다:

class ImmutablePoint {
  final int x;
  final int y;

  // 상수 생성자
  const ImmutablePoint(this.x, this.y);
}

void main() {
  // 동일한 인스턴스를 참조
  var point1 = const ImmutablePoint(1, 2);
  var point2 = const ImmutablePoint(1, 2);

  print(identical(point1, point2));  // true: 같은 인스턴스

  // 다른 인스턴스
  var point3 = ImmutablePoint(1, 2);  // const 없이 생성
  var point4 = ImmutablePoint(1, 2);

  print(identical(point3, point4));  // false: 다른 인스턴스
}

리다이렉팅 생성자

한 생성자에서 같은 클래스의 다른 생성자를 호출할 때 리다이렉팅 생성자를 사용합니다:

class Person {
  String name;
  int age;

  // 주 생성자
  Person(this.name, this.age);

  // 리다이렉팅 생성자
  Person.adult(String name) : this(name, 18);

  Person.child(String name) : this(name, 10);

  Person.fromJson(Map<String, dynamic> json)
      : this(json['name'] as String, json['age'] as int);
}

void main() {
  final person1 = Person.adult('홍길동');
  print('${person1.name}, ${person1.age}');  // 홍길동, 18

  final person2 = Person.fromJson({'name': '김철수', 'age': 25});
  print('${person2.name}, ${person2.age}');  // 김철수, 25
}

팩토리 생성자

팩토리 생성자는 매번 새 인스턴스를 생성하지 않아도 되는 생성자입니다. 캐싱, 인스턴스 재사용, 하위 클래스 인스턴스 반환 등의 경우에 유용합니다.

class Logger {
  final String name;

  // 로거 인스턴스 캐시
  static final Map<String, Logger> _cache = <String, Logger>{};

  // private 생성자
  Logger._internal(this.name);

  // 팩토리 생성자
  factory Logger(String name) {
    // 캐시에 이미 있으면 기존 인스턴스 반환
    return _cache.putIfAbsent(
      name,
      () => Logger._internal(name),
    );
  }

  void log(String message) {
    print('[$name] $message');
  }
}

void main() {
  final logger1 = Logger('UI');
  final logger2 = Logger('API');
  final logger3 = Logger('UI');  // 캐시된 인스턴스 재사용

  print(identical(logger1, logger3));  // true: 같은 인스턴스
  print(identical(logger1, logger2));  // false: 다른 인스턴스

  logger1.log('버튼 클릭됨');  // [UI] 버튼 클릭됨
  logger2.log('데이터 로드 중');  // [API] 데이터 로드 중
}

하위 클래스 인스턴스 반환

팩토리 생성자를 사용하여 조건에 따라 하위 클래스의 인스턴스를 반환할 수 있습니다:

abstract class Shape {
  // 팩토리 생성자
  factory Shape(String type) {
    switch (type) {
      case 'circle':
        return Circle(10);
      case 'rectangle':
        return Rectangle(10, 20);
      default:
        throw ArgumentError('지원하지 않는 도형 타입: $type');
    }
  }

  double get area;
}

class Circle implements Shape {
  final double radius;

  Circle(this.radius);

  @override
  double get area => 3.14 * radius * radius;
}

class Rectangle implements Shape {
  final double width;
  final double height;

  Rectangle(this.width, this.height);

  @override
  double get area => width * height;
}

void main() {
  final circle = Shape('circle');
  final rectangle = Shape('rectangle');

  print('원 면적: ${circle.area}');  // 원 면적: 314.0
  print('사각형 면적: ${rectangle.area}');  // 사각형 면적: 200.0
}

fromJson 팩토리 생성자

JSON 데이터로부터 객체를 생성하는 패턴은 매우 일반적입니다:

class User {
  final String name;
  final int age;
  final String email;

  User(this.name, this.age, this.email);

  // JSON에서 User 객체 생성
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      json['name'] as String,
      json['age'] as int,
      json['email'] as String,
    );
  }

  // User 객체를 JSON으로 변환
  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'age': age,
      'email': email,
    };
  }
}

void main() {
  final jsonData = {
    'name': '홍길동',
    'age': 30,
    'email': 'hong@example.com',
  };

  final user = User.fromJson(jsonData);
  print('${user.name}, ${user.age}, ${user.email}');  // 홍길동, 30, hong@example.com

  final json = user.toJson();
  print(json);  // {name: 홍길동, age: 30, email: hong@example.com}
}

정적 멤버

클래스의 특정 인스턴스가 아닌 클래스 자체에 속하는 멤버를 정의할 때 static 키워드를 사용합니다.

class MathUtils {
  // 정적 상수
  static const double PI = 3.14159;

  // 정적 변수
  static int calculationCount = 0;

  // 정적 메서드
  static double square(double num) {
    calculationCount++;
    return num * num;
  }

  static double cube(double num) {
    calculationCount++;
    return num * num * num;
  }
}

void main() {
  print('원주율: ${MathUtils.PI}');  // 원주율: 3.14159

  final sq = MathUtils.square(5);
  print('5의 제곱: $sq');  // 5의 제곱: 25.0

  final cb = MathUtils.cube(3);
  print('3의 세제곱: $cb');  // 3의 세제곱: 27.0

  print('계산 횟수: ${MathUtils.calculationCount}');  // 계산 횟수: 2
}

정적 멤버는 인스턴스를 생성하지 않고도 접근할 수 있으며, 클래스의 모든 인스턴스가 공유합니다.

추상 클래스와 인터페이스

추상 클래스

추상 클래스는 직접 인스턴스화할 수 없으며, 다른 클래스가 구현해야 하는 메서드와 프로퍼티를 정의합니다.

abstract class Animal {
  String name;

  Animal(this.name);

  // 추상 메서드 (구현 없음)
  void makeSound();

  // 구현된 메서드
  void sleep() {
    print('$name is sleeping');
  }
}

class Dog extends Animal {
  Dog(String name) : super(name);

  // 추상 메서드 구현
  @override
  void makeSound() {
    print('$name says Woof!');
  }
}

class Cat extends Animal {
  Cat(String name) : super(name);

  @override
  void makeSound() {
    print('$name says Meow!');
  }
}

void main() {
  // Animal animal = Animal('Generic');  // 오류: 추상 클래스는 인스턴스화할 수 없음

  final dog = Dog('Bobby');
  dog.makeSound();  // Bobby says Woof!
  dog.sleep();      // Bobby is sleeping

  final cat = Cat('Whiskers');
  cat.makeSound();  // Whiskers says Meow!
  cat.sleep();      // Whiskers is sleeping
}

인터페이스

Dart에는 별도의 interface 키워드가 없습니다. 모든 클래스가 암묵적으로 인터페이스 역할을 할 수 있습니다. 클래스를 인터페이스로 구현하려면 implements 키워드를 사용합니다.

// 인터페이스 역할을 하는 클래스
class Vehicle {
  void move() {
    print('Vehicle is moving');
  }

  void stop() {
    print('Vehicle stopped');
  }
}

// Vehicle 인터페이스 구현
class Car implements Vehicle {
  @override
  void move() {
    print('Car is driving');
  }

  @override
  void stop() {
    print('Car stopped');
  }
}

class Airplane implements Vehicle {
  @override
  void move() {
    print('Airplane is flying');
  }

  @override
  void stop() {
    print('Airplane landed');
  }
}

void main() {
  final car = Car();
  car.move();  // Car is driving
  car.stop();  // Car stopped

  final airplane = Airplane();
  airplane.move();  // Airplane is flying
  airplane.stop();  // Airplane landed

  // 다형성: Vehicle 인터페이스를 구현한 객체는 Vehicle 타입 변수에 할당 가능
  Vehicle vehicle = Car();
  vehicle.move();  // Car is driving
}

상속

Dart에서는 extends 키워드를 사용하여 클래스를 상속받습니다. Dart는 단일 상속만 지원합니다.

class Person {
  String name;
  int age;

  Person(this.name, this.age);

  void introduce() {
    print('안녕하세요, 저는 $name이고 $age살입니다.');
  }
}

class Student extends Person {
  String school;

  // 상위 클래스 생성자 호출
  Student(String name, int age, this.school) : super(name, age);

  // 메서드 오버라이드
  @override
  void introduce() {
    super.introduce();  // 상위 클래스 메서드 호출
    print('저는 $school에 다니고 있습니다.');
  }

  // 새로운 메서드 추가
  void study() {
    print('$name이(가) 공부하고 있습니다.');
  }
}

void main() {
  final person = Person('홍길동', 30);
  person.introduce();  // 안녕하세요, 저는 홍길동이고 30살입니다.

  final student = Student('김철수', 20, '서울대학교');
  student.introduce();  // 안녕하세요, 저는 김철수이고 20살입니다.
                        // 저는 서울대학교에 다니고 있습니다.
  student.study();      // 김철수이(가) 공부하고 있습니다.
}

믹스인(Mixin)

믹스인은 클래스 간에 코드를 재사용하는 방법을 제공합니다. with 키워드를 사용하여 믹스인의 기능을 클래스에 추가할 수 있습니다.

// 믹스인 정의
mixin Swimming {
  void swim() {
    print('수영하고 있습니다.');
  }
}

mixin Flying {
  void fly() {
    print('날고 있습니다.');
  }
}

// 믹스인 사용
class Animal {
  String name;

  Animal(this.name);

  void eat() {
    print('$name이(가) 먹고 있습니다.');
  }
}

class Bird extends Animal with Flying {
  Bird(String name) : super(name);
}

class Fish extends Animal with Swimming {
  Fish(String name) : super(name);
}

class Duck extends Animal with Swimming, Flying {
  Duck(String name) : super(name);
}

void main() {
  final bird = Bird('참새');
  bird.eat();   // 참새이(가) 먹고 있습니다.
  bird.fly();   // 날고 있습니다.

  final fish = Fish('금붕어');
  fish.eat();   // 금붕어이(가) 먹고 있습니다.
  fish.swim();  // 수영하고 있습니다.

  final duck = Duck('오리');
  duck.eat();   // 오리이(가) 먹고 있습니다.
  duck.swim();  // 수영하고 있습니다.
  duck.fly();   // 날고 있습니다.
}

믹스인 제한:

// on 키워드로 믹스인을 특정 클래스에만 사용하도록 제한
mixin CanFly on Bird {
  void fly() {
    print('새처럼 날고 있습니다.');
  }
}

class Bird {
  String name;
  Bird(this.name);
}

class Eagle extends Bird with CanFly {
  Eagle(String name) : super(name);
}

// 다음 코드는 컴파일 오류 발생
// class Airplane with CanFly { }  // 오류: 'on Bird'로 제한됨

생성자 초기화 패턴

기본값과 명명된 매개변수

class Person {
  String name;
  int age;
  String? address;  // nullable 필드

  // 명명된 매개변수와 기본값
  Person({
    required this.name,
    required this.age,
    this.address,
  });
}

void main() {
  final person1 = Person(name: '홍길동', age: 30);
  final person2 = Person(name: '김철수', age: 25, address: '서울시');
}

초기화 간소화

class Point {
  final int x;
  final int y;

  // 간결한 생성자 문법
  const Point(this.x, this.y);

  // 명명된 매개변수
  const Point.origin()
      : x = 0,
        y = 0;
}

연산자 오버로딩

클래스에 대한 연산자 동작을 재정의할 수 있습니다:

class Vector {
  final double x;
  final double y;

  const Vector(this.x, this.y);

  // 덧셈 연산자 오버로딩
  Vector operator +(Vector other) {
    return Vector(x + other.x, y + other.y);
  }

  // 뺄셈 연산자 오버로딩
  Vector operator -(Vector other) {
    return Vector(x - other.x, y - other.y);
  }

  // 비교 연산자 오버로딩
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Vector &&
           other.x == x &&
           other.y == y;
  }

  // hashCode 오버라이드 (== 연산자를 오버라이드할 때 항상 필요)
  @override
  int get hashCode => x.hashCode ^ y.hashCode;

  @override
  String toString() => 'Vector($x, $y)';
}

void main() {
  final v1 = Vector(1, 2);
  final v2 = Vector(3, 4);

  final sum = v1 + v2;
  print(sum);  // Vector(4.0, 6.0)

  final diff = v2 - v1;
  print(diff);  // Vector(2.0, 2.0)

  print(v1 == Vector(1, 2));  // true
  print(v1 == v2);            // false
}

결론

Dart의 클래스 시스템은 강력하고 유연합니다. 생성자와 팩토리를 통해 다양한 객체 생성 패턴을 구현할 수 있으며, 상속, 믹스인, 인터페이스 구현을 통해 코드 재사용과 다형성을 달성할 수 있습니다.

이러한 객체 지향 기능을 잘 활용하면 유지 보수하기 쉽고 확장 가능한 코드를 작성할 수 있습니다. 다음 장에서는 Dart의 비동기 프로그래밍에 대해 알아보겠습니다.