[Live Study 5주차] 클래스

이호석·2022년 7월 18일
0

라이브 스터디 1/3지점에 도달했습니다. 벌써 5주차라는게 신기하네요 시간이 너무너무 빠릅니다..

목표

자바의 Class에 대해 학습하세요.

학습할 것

  • 클래스 정의하는 방법
  • 객체 만드는 방법(new 키워드 이해하기)
  • 메소드 정의하는 방법
  • 생성자 정의하는 방법
  • this 키워드 이해하기

과제 (Optional)

  • int 값을 가지고 있는 이진 트리를 나타내는 Node 라는 클래스를 정의하세요.
  • int value, Node left, right를 가지고 있어야 합니다.
  • BinrayTree라는 클래스를 정의하고 주어진 노드를 기준으로 출력하는 bfs(Node node)와 dfs(Node node) 메소드를 구현하세요.
  • DFS는 왼쪽, 루트, 오른쪽 순으로 순회하세요.



1. 클래스를 정의하는 방법

객체지향에서 클래스는 중요한 의미를 가진다.
자바에서 클래스는 서로 관계가 깊은 변수, 함수를 하나의 클래스라는 틀로 묶어서 정의해 함께 다룰 수 있게 하여 객체지향의 특성을 살릴 수 있다.

좀 더 나아가 클래스에 대해 알아보고 정의하는 방법을 살펴보자


[클래스의 구성요소]

클래스는 다음과 같은 구성요소를 이용해 정의할 수 있다.

  • 필드: 클래스의 포함된 변수, 객체의 상태를 나타낸다.
    • 클래스 변수: 클래스 영역에 존재하는 변수들 중 static 키워드를 가지는 변수
    • 인스턴스 변수: 클래스 영역에 존재하는 변수들 중 static 키워드를 가지지 않은 변수
    • 지역 변수: 클래스의 각 메소드 내에서 사용되는 변수
  • 메소드: 객체의 행위를 나타냄, 클래스에 포함된 함수를 말한다.
    • 클래스 메소드: 클래스 영역에 존재하는 메소드 중에서 static 키워드를 가진 메소드
    • 인스턴스 메소드: 클래스 영역에 존재하는 메소드 중 static 키워드가 없는 메소드
  • 생성자: 클래스를 가지고 객체를 생성할 때 객체의 생성과 동시에 인스턴스 변수를 원하는 값으로 초기화 해줌(생성자의 이름은 해당 클래스의 이름과 같아야 함)

추가적으로 필드를 초기화하기 위해 사용되는 초기화 블록이 존재한다.

  • 초기화 블록: 블록에 static 키워드를 붙이면 클래스 변수를, 그냥 블록만 사용하면 인스턴스 변수가 생성자보다 먼저 호출되며, 초기화를 진행한다.

클래스를 정의할때는 위에서 설명한 요소들이 반드시 필수값이 아니다. 즉 구성하고자 하는 목적에 맞게 필드 및 메소드들을 사용자가 직접 구성하면 된다. 아래는 예시로 TestClass를 보여준다.

package javaclass;

public class TestClass {
	// 인스턴스 필드
    private int instanceField;
    // 클래스 필드
    private static int classField;

    // 생성자
    public TestClass() {
        System.out.println(instanceField);
        System.out.println(classField);
    }
    
    // 인스턴스 변수 초기화 블록
    {
        System.out.println("생성자 보다 먼저 실행됨");
        instanceField = 10;
    }

    // 클래스 변수 초기화 블록
    static {
        System.out.println("생성자 보다 먼저 실행됨");
        classField = 20;
    }

    // 인스턴스 메소드
    public int getInstanceField() {
        return instanceField;
    }

    // 인스턴스 메소드
    public int getClassField() {
        return classField;
    }

    // 클래스 메소드
    public static void main(String[] args) {
        TestClass testClass = new TestClass();
    }
}

[출력]
생성자 보다 먼저 실행됨
생성자 보다 먼저 실행됨
10
20

추가적으로 static이 붙은 필드, 메소드는 클래스 생성시 이미 생성되어있고, 객체 생성없이 사용 가능하므로, 멤버 변수, 메소드와 같은 non-static 요소에 접근할 수 없다.
하지만 반대로 non-static 요소들은 static 요소에 접근할 수 있다.


[접근 제어자]

클래스, 메소드, 필드에 사용할 수 있는 키워드들 중 접근제어자와 기타 제어자가 존재한다.
접근제어자는 사용자에게 굳이 알 필요가 없는 정보를 은닉하기 위해 사용되며 객체지향의 개념 중 하나이다. 메소드, 필드에 접근제어자를 이용했을때의 제한 범위는 다음과 같다.


[기타 제어자]

  • final: 변경할 수 없다는 의미, 필드에 사용하면 값을 변경할 수 없는 상수가 되고, 메소드에 사용하면 해당 메소드는 Overriding 할 수 없음
  • static: 필드에 사용하면 클래스 변수로, 메소드에 사용하면 클래스 메소드로 만든다. 또한 초기화 블록에서도 사용됨
  • abstract: 선언부만 존재하고 구현부는 없는 추상 메소드에 반드시 붙여줘야 함, 하나 이상의 추상 메소드가 존재하는 클래스라면 반드시 클래스에도 붙여줘야 함.
    • 상수를 선언할때 보통 static과 final키워드를 같이 이용해 전역적으로 상수를 선언하여 사용함
    • 또한 (static, private)와 abstract는 같이 사용할 수 없다.
      abstract를 사용하면 반드시 상속을 해주어 메소드를 구현해야 하는데 static은 인스턴스가 없어도 사용 가능해야 하며, private은 자식 클래스에서 해당 추상 메소드를 접근할 수 없게 하므로 같이 사용할 수 없다.

[클래스에서의 접근 제어자 및 기타 제어자]

클래스에서 접근 제어자 및 기타 제어자의 역할을 알아보자

클래스는 보통 접근, 기타 제어자 중에 default, public, abstract, final만 사용된다.
각각의 의미는 다음과 같다.

  • 접근 제어자
    • (default): 동일한 패키지 내에 정의된 클래스에 의해서만 인스턴스 생성 가능
    • public: main 클래스로 사용, 하나의 소스 파일에 하나의 public 클래스만 선언 가능
  • 기타 제어자
    • abstract: 추상 메소드를 하나 이상 가지면 붙여줘야 함
    • fianl: 해당 키워드가 붙은 클래스는 다른 클래스가 해당 클래스를 상속받을 수 없음

만약 중첩 클래스를 이용한다면 모든 접근 제어자를 사용할 수 있다.

  • 중첩 클래스: 클래스 안에 클래스를 말함, static, non-static 중첩 클래스가 존재한고 non-static 중첩 클래스를 inner class라 말한다.

[static, non-static 중첩 클래스의 차이점]

non-static 중첩 클래스는 포함된 상위 클래스의 필드, 메소드에 접근할 수 있다.
static 중첩 클래스는 상위 클래스와 분리시켜, 상위 클래스의 필드, 메소드에 접근할 수 없다.

public class TestClass {
    private int instanceField;
    private static int classField;
	
    // non-static 중첩 클래스
    public class Name {
        public Name() {
            instanceField = 10;
            int instanceField = getInstanceField();
        }
    }
    
    // static 중첩 클래스
    private static class Age {
        public Age() {
            // instanceField = 10;	// 접근 불가
            // int instanceField = getInstanceField();	// 접근 불가
        }
    }
}

또한 중첩 클래스에서는 모든 접근 제어자가 사용 가능하다.



2. 객체 만드는 방법

위에서 예시를 들었던 TestClass의 인스턴스를 만들어 보겠습니다.

TestClass test = new TestClass();

클래스는 객체에 대한 blueprint를 제공하므로 클래스에서 인스턴스를 생성할 수 있다. 위의 코드는 인스턴스를 만들고 참조 변수에 할당한다.

위의 TestClass의 인스턴스 생성 과정은 3가지 부분으로 나눌 수 있다.
1. 선언: 인스턴스 유형을 결정하고, 변수의 이름을 정한다.
2. 인스턴스화: new 키워드는 객체를 생성하는 Java의 연산이다.
3. 초기화: new 연산자는 생성자를 호출하여 new 뒤에 객체를 초기화 한다.


[클래스 인스턴스화]

new 연산자는 new 인스턴스에 대한 메모리를 할당하고, 해당 메모리에 대한 참조를 반환해 클래스를 인스턴스화 한다. 또한 new 연산자는 해당 클래스의 생성자를 호출한다.

new 연산자를 사용하는 규칙은 다음과 같다.

  1. new 연산자는 후위 인수로 생성자 호출이 꼭 필요하다. 생성자의 이름은 클래스의 이름으로 제공된다.
  2. new 연산자는 생성한 개체에 대한 참조를 반환함
  3. new 연산자가 반환한 참조는 굳이 해당 참조변수에 할당할 필요가 없고, 직접적으로 이용 가능하다
    • int num = new TestClass().getInstanceField();

만약 생성자에서 필드에 따로 값을 초기화 하지 않았다면 필드에는 자동적으로 변수의 기본값으로 초기화가 실행된다.


[JVM에선?]

main 함수에서 TestClass test = new TestClass()로 객체를 생성하면
TestClass test는 지역변수이며 참조변수이고, new TestClass()는 객체가 된다.

따라서 Runtime Data Area에 TestClass test는 지역변수 이므로 Stack Area 영역에 생성되고
TestClass의 인스턴스는 Heap Area 영역에 생성된다.

test 참조변수는 Heap 영역에 TestClass의 인스턴스가 위치한 주소의 값을 가지고 있다.



3. 메소드 정의하는 방법

가장 전형적인 메소드는 다음과 같다.

public class TestClass {
	...
    
    public int add(int a, int b) {
    	return a + b;
    }
}

메소드를 정의하는데 반드시 필요한 요소는 리턴 타입, 이름, 괄호 쌍(), 및 메소드 사이의 본문{}이며, 접근 제어자는 꼭 필요한 요소는 아니다.
메소드의 본문을 구현부라 하고, 나머지 요소들을 선언부라 한다.


[메소드 구성]

  1. 접근 제어자(필수x): private, public등
  2. 리턴 타입: 메소드에서 반환할 값의 데이터 타입, void라면 반환 값 없음
  3. 메소드 이름: 메소드는 반드시 이름이 필요합니다.
  4. 매개 변수(필수x): ()안에 메소드에 전달할 매개변수를 표시, 없다면 비워도 됨
  5. 중괄호로 감싼 메소드 본문: 지역 변수 및 메소드의 동작을 구현한 코드들이 위치함
  6. 기타 제어자: abstract, final, static 등

    메서드 명과 파라미터의 순서, 타입, 개수를 메소드 시그니처라 말한다.

    또한 접근 제어자의 경우 이전의 클래스 설명에서와 동일하게 적용된다.

추가적으로 기타 제어자(보통 접근제어자와 리턴타입 사이에 명시)를 살펴보자

  • static: 메소드에 붙이게 되면 클래스 메소드가 됨
  • abstract: 추상 메소드, 메소드 본문이 없고 선언부만 존재
  • final: 해당 키워드가 붙은 메소드가 상속되었을때 final 키워드가 붙은 메소드는 오버라이딩 할 수 없다.

[메소드 명명]

기본적으로 메소드 명은 lowerCamelCase로 작성되어야 하며 동사로 시작하는 다중 단어 이름이어야 하며 그 뒤에 형용사, 명사 등이 와야 합니다.

[예시]
run
runFast
getBackground
getFinalData
compareTo
setX
isEmpty

[메소드 오버로딩]

Java는 메소드 오버로딩을 지원한다. 메소드 시그니처가 서로 다른것을 구분할 수 있다는 의미이기도 하다.

규칙은 단순하다. 리턴 타입과 메소드 이름이 동일하고 매개변수의 타입 혹은 개수를 다르게 가져가면 이를 메소드 오버로딩이라 한다.

    public void draw(String s) {
        ...
    }
    public void draw(int i) {
        ...
    }
    public void draw(double f) {
        ...
    }
    public void draw(int i, double f) {
        ...
    }

[메소드 사용하기]

메소드를 사용하기 위해서는 기본적으로 클래스의 인스턴스를 생성해야 한다.
(클래스 메소드라면 클래스명으로 사용가능)

이후 인스턴스명.메소드(인자);와 같은 형식으로 사용하며 반환타입이 void가 아니라면 변수에 메소드가 반환한 값을 저장할 수 있다.

위에서

public class TestClass {
	...
    
    public int add(int a, int b) {
    	return a + b;
    }
}
.
.
.

class Main {
	public static void main(String[] args) {
    	// TestClass 인스턴스 생성
        TestClass test = new TestClass();
        
        int numA = 10;
        int numB = 20;
        
        // 메소드 호출, numA + numB의 값 반환
        int result = test.add(numA, numB);
    }
}

4. 생성자 정의하는 방법

생성자에서도 메소드 시그니처가 적용된다. 다만, 생성자는 리턴타입이 존재해선 안되고, 메소드의 이름이 반드시 클래스의 이름과 동일해야한다.

생성자는 매개변수 타입, 개수의 차이를 두어 여러개를 생성할 수 있다. 만약 명시적으로 생성자를 작성하지 않아도 컴파일시 자동으로 default 생성자가 생성된다.(매개변수 없음) 하지만 매개변수가 존재하는 생성자를 작성하고, 기본 생성자를 작성하지 않으면 default 생성자는 자동생성되지 않으므로 유의하자!

생성자는 이전에도 말했듯이 new 연산시 호출되므로 만약 private로 선언하게 된다면 해당 클래스를 다른 소스 파일에서 인스턴스화 할 수 없으니 주의하자.
특별한 목적이 아니라면 생성자는 보통 public을 이용한다.

public class TestClass {

	// 기본 생성자 (default constructor)
	public TestClass() {
    	...
    }
    
    public TestClass(int a, int b) {
    	// 기본생성자가 없고 이 생성자만 존재한다면
        // new TestClass(); 는 기본생성자를 호출하므로 오류를 반환
    }
}



5. this 키워드

this는 참조변수이며 인스턴스 메소드 또는 생성자 내에서 현재 호출한 자기 자신을 참조하는 키워드 입니다.

즉 this가 가리키는 것은 자기 자신 (해당 메소드 혹은 생성자를 호출한 인스턴스)가 되며 이를 이용해 인스턴스 메소드, 생성자 내에서 현재 인스턴스의 모든 멤버들을 참조할 수 있다.

public class TestClass {
    public void printThis() {
        System.out.println(this);
    }
}
.
.
.

public class Test {
	public static void main(String[] args) {
    	TestClass test = new TestClass();
        
        System.out.println(test);
        System.out.println(test.printThis());
    }
}
[출력]
javaclass.TestClass@59a6e353
javaclass.TestClass@59a6e353

실제로 TestClass의 인스턴스를 생성하고, this를 콘솔에 출력하는 메소드와 인스턴스 자체를 콘솔에 출력하면 this가 자기 자신이라는 것을 확인할 수 있다.

그렇다면 this는 언제 사용할까?


  1. 인스턴스 변수와 동일한 변수가 존재할 경우 구분할때 사용됨
public class TestClass {
	private int num;
    
	public int setNum(int num) {
		this.num = num;	// this.num은 필드를, num은 매개변수를 나타냄 이름이 동일하면 다음과 같이 구분함
	}
}

  1. 동일 클래스 내, 한 생성자에서 다른 생성자를 호출할때 사용
    • this() 메소드를 사용하며, 이는 생성자 내부에서만 사용할 수 있는 메소드다.
public class TestClass {
	private int num;
    
    public TestClass() {
    	// this() 메소드 이용
        this(num);	// 생성자가 다른 생성자를 호출
    }
    
    public TestClass(int num) {
    	this.num = num;
    }
}

  1. 자기자신을 매개변수로 전달하거나, 반환할때 사용
public class TestClass {
	private int num;
    
    // 매개변수로 객체를 전달받음
    public void setClass(Main test) {
    	. . .
    }
    
    // 자기 자신을 반환
    public TestClass getClass() {
    	return this;
    }
}

public class Main {
	public void run() {
    	TestClass test = new TestClass();
        test.setClass(this);	// Main 클래스의 인스턴스 전달
    }
}



6. 과제 (int 값을 가지고 있는 이진 트리를 나타내는 Node 라는 클래스를 정의)

이진트리는 모든 노드의 자식 노드가 최대 2까지인 트리를 말한다.
이진 트리의 종류는 다음과 같다.

  • 이진 탐색 트리: 각 노드의 왼쪽 서브트리의 값은 해당 노드의 값보다 작고, 오른쪽 서브 트리의 값들은 해당 노드의 값보다 커야한다.
  • 정 이진트리: 각 내부 노드가 두 개의 자식 노드 혹은 0개의 자식 노드만을 갖음 홀수는 x
  • 포화 이진트리: 모든 레벨의 노드가 2개로 가득 차 있는 이진 트리
  • 완전 이진트리: 마지막 레벨을 제외하고 모든 레벨이 꽉 차있으며 마지막 레벨은 반드시 왼쪽부터 오른쪽으로 노드가 순차적으로 차있어야 한다.

[탐색하고자 하는 이진트리의 구조]

    /**
     * [트리의 구조]
     *              n1
     *            /    \
     *          n2     n3
     *         /  \    /
     *        n4  n5  n6
     *        /   / \
     *      n7   n8  n9
     */

도출할 탐색 결과

  • BFS: 1 2 3 4 5 6 7 8 9
  • DFS: 7 4 2 8 5 9 1 6 3

[Node 클래스]

public class Node {
    private int data;
    private Node left;
    private Node right;

    public Node() {
    }

    public Node getLeft() {
        return left;
    }

    public Node getRight() {
        return right;
    }

    public Node(int data) {
        this.data = data;
    }

    public void setLeft(Node left) {
        this.left = left;
    }

    public void setRight(Node right) {
        this.right = right;
    }

    public int getData() {
        return data;
    }

    public void setData(int data) {
        this.data = data;
    }
}

이진트리는 왼쪽, 오른쪽 자식 노드를 가지고 정수 데이터를 저장


[BinaryTree 클래스]

import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;

public class BinaryTree {
    private Node root;
    public StringBuilder sb = new StringBuilder();

    public void setRoot(Node root) {
        this.root = root;
    }

    public Node getRoot() {
        return root;
    }

    public Node makeNode(Node left, int data, Node right) {
        Node node = new Node();
        node.setData(data);
        node.setLeft(left);
        node.setRight(right);
        return node;
    }

    public void BFS(Node root) {
        Queue<Node> que = new LinkedList<>();
        que.add(root);

        while (!que.isEmpty()) {
            Node node = que.poll();

            appendData(node);

            if (node.getLeft() != null) {
                que.add(node.getLeft());
            }
            if (node.getRight() != null) {
                que.add(node.getRight());
            }
        }
    }
    
	// inorder DFS
    public void recurDFS(Node root) {
        if (root == null) {
            return;
        }
        recurDFS(root.getLeft());
        appendData(root);
        recurDFS(root.getRight());
    }
    
	// preorder DFS
    public void stackDFS(Node root) {
        Stack<Node> stack = new Stack<>();
        stack.push(root);

        while (!stack.isEmpty()) {
            Node node = stack.pop();

            if (node.getRight() != null) {
                stack.push(node.getRight());
            }
            appendData(node);
            if (node.getLeft() != null) {
                stack.push(node.getLeft());
            }
        }
    }

    private void appendData(Node node) {
        sb.append(node.getData());
    }

}

기본적으로 inorder로 DFS를 하려면 제일 간단하게 재귀를 이용해 구할 수 있다.
stack을 이용하면 좀 더 제약조건을 많이 걸어야함
위 코드에서 Stack을 이용한 stackDFS은 preOrder로 순회를 한다.


[Test Code]

import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class BinaryTreeTest {

    private BinaryTree tree = new BinaryTree();

    /**
     * [트리의 구조]
     *         n1
     *        /    \
     *      n2      n3
     *     /  \     /
     *    n4  n5   n6
     *   /    / \
     *  n7   n8  n9
     */
    @BeforeEach
    void beforeEach() {
        Node n9 = tree.makeNode(null, 9, null);
        Node n8 = tree.makeNode(null, 8, null);
        Node n7 = tree.makeNode(null, 7, null);
        Node n6 = tree.makeNode(null, 6, null);
        Node n5 = tree.makeNode(n8, 5, n9);
        Node n4 = tree.makeNode(n7, 4, null);
        Node n3 = tree.makeNode(n6, 3, null);
        Node n2 = tree.makeNode(n4, 2, n5);
        Node n1 = tree.makeNode(n2, 1, n3);
        tree.setRoot(n1);
    }

    @AfterEach
    void afterEach() {
        tree.sb = new StringBuilder();
    }


    @Test
    @DisplayName("BFS test")
    void BFS() {
        // given
        String result = "123456789";

        // when
        tree.BFS(tree.getRoot());
        String answer = tree.sb.toString();

        // then
        assertEquals(result, answer);
    }

    @Test
    @DisplayName("inorder DFS Test")
    void recurDFS() {
        // given
        String result = "742859163";

        // when
        tree.recurDFS(tree.getRoot());
        String answer = tree.sb.toString();
        System.out.println(answer);

        // then
        assertEquals(result, answer);
    }

    @Test
    @DisplayName("[번외] stack DFS test: 전위 연산(DLR)")
    void stackDFS() {
        // given
        String result = "124758936";

        // when
        tree.stackDFS(tree.getRoot());
        String answer = tree.sb.toString();

        // then
        assertEquals(result, answer);
    }
}

실행 결과는 아래와 같이 예상한 결과로 순회를 마치고, 테스트를 통과한다.



References

profile
꾸준함이 주는 변화를 믿습니다.

0개의 댓글