Technology/Java

Java의 다양한 연산자(operator) 다루기

ikjo 2022. 8. 4. 17:10
목차

1. 연산자와 피연산자

2. 연산자의 종류
  2-1. 산술 연산자
  2-2. 관계 연산자
  2-3. 논리 연산자
  2-4. 비트 연산자
  2-5. instanceof 연산자
  2-6. 대입(=) 연산자
  2-7. 화살표(->) 연산자
  2-8. 3항 연산자
  
3. 연산자의 우선 순위와 결합 규칙
  3-1. 연산자의 우선 순위
  3-2. 연산자의 결합 규칙

 

연산자와 피연산자

연산자(operator)는 사칙 연산(+, -, *, /), 나머지 연산(%), 비교 연산자 등 '연산을 수행하는 기호'를 말한다. 이때 연산자가 연산을 수행하려면 변수, 리터럴, 수식 등 반드시 '연산의 대상'이 있어야 하는데, 이를 '피연산자(operand)'라고 한다. 이처럼 연산자는 피연산자로 연산을 수행하고 이후에는 항상 결과값을 반환한다는 특징이 있다.

 

참고로 연산자와 피연산자를 조합하여 계산하고자하는 바를 표현한 것을 '식(expression)'이라고 하며, 이 식을 평가(계산)하면 단 하나의 결과를 얻는다.

 

 

연산자의 종류

산술 연산자

산술 연산자에는 사칙 연산자나머지 연산자(%)가 있다. 사칙 연산자에는 덧셈(+), 뺄셈(-), 곱셈(*), 나눗셈(/)이 존재한다. 이때 곱셈, 나눗셈, 나머지 연산자의 경우 덧셈과 뺄셈 연산자보다 우선순위가 높다는 것에 유의하자. 아래 식에서는 10 * 5가 먼저 연산된 뒤 -5와 연산되어 최종적으로 45가 출력되는 것을 확인할 수 있다.

 

    System.out.println(10 * 5 - 5); // 45

 

피연산자가 정수형인 경우 이를 0으로 나눌 수는 없는데, 만일 0으로 나눈다면 컴파일은 정상 처리되지만 실행 중 에러(ArithmeticException)가 발생하게 된다. 이때 0이 아닌 0.0f나 0.0d로 나눌 수는 있지만 그 결과는 Infinity(무한대) 값이 나온다.

 

    System.out.println(10 / 0); // ArithmeticException 예외 발생
    System.out.println(10 / 0.0); // Infinity

 

나머지 연산자(%)는 주로 짝수, 홀수, 배수 검사 등에 사용되는데, 나눗셈(/)과 마찬가지로 오른쪽 피연산자로 0을 사용할 수는 없다. 참고로 나머지 연산자는 나누는 수로 음수도 허용하지만 부호가 무시되므로 결과는 양수일 때와 동일하다.

 

    System.out.println(10 % 3); // 1
    System.out.println(10 % 0); // ArithmeticException 예외 발생
    System.out.println(10 % 0.0); // NaN (meaning : Not a Number)
    System.out.println(10 % -3); // -3으로 나누었지만 결과는 3으로 나누었을 때와 동일한 1

 

이러한 산술 연산자는 '이항 연산자'로서 두 개의 피연산자 연산 시 피연산자의 타입을 일치시킨 후에 연산한다는 점에 유의해야한다. 이후에 다룰 관계 연산자나 논리 연산자 등의 이항 연산자에서도 동일하게 적용된다.

 

관계 연산자

관계 연산자는 대소비교 연산자등가비교 연산자로 나뉜다. 우선 대소비교 연산자는 <, >, <=, >= 와 같이 두 피연산자의 값의 크기를 비교하는 연산자들로 구성되며, 이 연산자의 반환 값은 true 또는 false이다. 또한 boolean형을 제외한 나머지 모든 기본형 타입의 피연산자들에 대해 사용할 수 있다.(참조형 타입의 피연산자에는 사용 불가)

 

1. > : 왼쪽 값이 오른쪽 값 보다 크면 true

2. < : 오른쪽 값이 왼쪽 값 보다 크면 true

3. >= : 왼쪽 값이 오른쪽 값 보다 크거나 같으면 true

4. <= : 오른쪽 값이 왼쪽 값 보다 크거나 같으면 true

 

그 다음 등가비교 연산자에는 ==와 !=가 있다. 이는 두 피연산자의 값이 같은지 다른지를 비교하는 연산자로 기본형뿐만 아니라 참조형 타입의 피연산자들에도 사용할 수 있다. 다만, 기본형과 참조형 간의 비교는 불가능하다.

 

이때 10.0 == 10.0f의 결과는 무엇일까? 정답은 true이다. 컴파일러가 두 피연산자를 동일한 타입으로 변경하는 과정에서10.0f를 10.0(double형)으로 변환시키므로 true가 되는 것이다.

 

반면에 0.1 == 0.1f의 결과는 무엇일까? 정답은 false이다. 마찬가지로 컴파일러가 0.1f를 0.1로 변환해주지만 0.1f에서 이미 발생한 오차까지는 double형으로 바꾸지 못하기 때문이다. 따라서 정밀도에 의한 차이로 false가 나오는 것이다.

 

참고로 String 간의 비교에서도 ==나 !=을 사용할 수 있는데, String을 new 연산자로 생성했는지, 리터럴로 생성했는지에 따라 결과가 다르다.  우선 리터럴로 생성된 String 객체들은 이미 존재하는 동일한 리터럴을 참조하고 있으므로 == 연산 결과 true가 나온다. 반면에 new 연산자로 생성된 String 객체들은 같은 값으로 생성되었어도 JVM 힙 영역에 새롭게 저장되어 독립적인 객체들로 존재하여 == 연산 결과 false가 나온다. 이때 equals() 메서드를 활용한다면 두 객체간 내부 데이터가 같은지 비교할 수 있다.

 

        String a = "APPLE";
        String b = "APPLE";
        System.out.println(a == b); // true
        
        String c = new String("APPLE");
        String d = new String("APPLE");
        System.out.println(c == d); // false
        System.out.println(c.equals(d)); // true

 

논리 연산자

논리 연산자는 &&(AND), ||(OR), !(NOT)으로 구성되며, 이를 통해(정확하게는 &&와 ||을 통해) 둘 이상의 조건을 하나의 식으로 표현할 수 있게 해준다.

 

여기서 중요한 점은 && 연산의 경우 좌측 피연산자의 결과가 false라면 전체 연산 결과가 어찌됐든 false가 반환될 것이므로 우측 피연산자는 실행되지 않는다. 마찬가지로 || 연산의 경우 좌측 피연산자의 결과가 true라면 전체 연산 결과가 항상 true가 반환되므로 우측 피연산자는 실행되지 않는다.

 

        int[] arr = new int[3];
        int i = 3;
        
        if (arr[i] == 0 && i < 3) { // ArrayIndexOutOfBoundsException 발생
            // ...
        }
        
        if (i < 3 && arr[i] == 0) { // 정상 동작
            // ...
        }

 

따라서 같은 조건식이라도 피연산자의 위치에 따라 연산속도가 달라질 수 있으므로 && 연산의 경우 false일 확률이 높은 피연산자를 좌측에, || 연산의 경우 true일 확률이 높은 피연산자를 좌측에 배치하면 좀 더 효율적인 연산 처리가 가능해진다.

 

논리 부정 연산자 !의 경우에는 조건식을 좀 더 보기 좋게 만들어주는 경우토글 버튼 등을 구현할 때 주로 사용된다.

 

        // x가 3 이하, 5 이상인지 검증
        int x = 3;
        System.out.println(!(3 < x && x < 5)); // true

 

비트 연산자

비트 연산자는 피연산자를 이진수로 표현했을 때 비트 단위로 논리 연산을 수행하는 연산자로서 &, |, ^, ~, <<, >>가 있다. 이러한 비트 연산자는 피연산자로 실수를 허용하지 않고 오직 정수(문자 포함)만 허용한다. 이때 비트 연산에서도 피연산자의 타입을 일치시키는 산술 변환이 일어날 수도 있다.

 

우선 &(AND) 연산자는 피연산자 양 쪽이 모두 1일 경우 1을 반환한다. 주로 특정 비트 값 뽑아낼 때 사용된다.

 

    System.out.println(6 & 3); // 2 (110 & 011 -> 010)

 

|(OR) 연산자는 피연산자 중 한 쪽이라도 1일 경우 1 반환한다. 주로 특정 비트 값을 변경 시에 사용한다.

 

    System.out.println(6 | 3); // 7 (110 & 011 -> 111)

 

^(XOR) 연산자는 피연산자의 값이 서로 다를 때만 1 반환된다. 주로 간단한 암호화에 사용한다.

 

    System.out.println(6 ^ 3); // 5 (110 & 011 -> 101)

 

비트 전환 연산자 ~는 피연산자를 2진수로 표현했을 때, 0은 1로, 1은 0으로 바꾸는 연산자로서 이를 통해 피연산자의 '1의 보수'를 얻을 수 있다. 이때 피연산자의 타입이 int 보다 작으면 int로 자동 형변환 후에 연산한다.

 

    System.out.println(~5); // -6 (0101 -> 1010)
    // 1010은 5의 1의 보수 값이나, println을 통해 10진수로 출력되어 -6으로 출력됨

 

쉬프트 연산자는 피연산자를 2진수로 표현했을 때 각 자리를 오른쪽 또는 왼쪽으로 이동시키는 연산자인데, 좌측 피연산자의 타입이 int 보다 작을 경우 산술 변환이 일어나 int 타입이 되지만 우측 피연산자에 대해선 산술 변환이 일어나지 않는 것이다. 즉, 피연산자간 타입을 일치시킬 필요가 없다는 것이다.

 

우선 << 연산의 경우 왼쪽으로 각 자리를 이동시키는데, 이때 자리이동으로 저장 범위를 벗어난 값들은 버려지고 빈자리는 0으로 채워진다. x << n 연산에 대한 결과 값은 x * 2^n의 값과 같다.

 

    System.out.println(3<<2); // 12 (0011 -> 1100)

 

>> 연산의 경우 오른쪽으로 각 자리를 이동시키는데, 마찬가지로 빈자리는 0으로 채워지나 다만, 부호있는 정수의 경우 부호를 유지하기 위해 왼쪽 피연산자가 음수인 경우 빈자리를 1로 채운다. x >> n 연산에 대한 결과 값은 x / 2^n의 값과 같다.

 

    System.out.println(12>>2); // 3 (1100 -> 0011)

 

이처럼 시프트 연산의 경우 나눗셈 연산자(/)를 이용해서도 구현할 수 있으나 나눗셈 연산자 보다 연산 속도가 더 빠르다는 장점이있지만 가독성이 안 좋다는 단점이 있다.

 

instanceof 연산자

instanceof 연산자는 만들어진 객체가 특정 클래스의 인스턴스인지 검증하는 연산자로 true 또는 false를 반환한다. 이때 유의해야할 점은 객체 참조 변수의 타입이 아닌 실제 객체의 타입에 의해 처리된다는 점이다.

 

class Main {

    public static void main(String[] args) {
        Animal animal = new Animal();
        Animal tiger = new Tiger();
        System.out.println(animal instanceof Animal); // true
        System.out.println(animal instanceof Tiger); // false
        System.out.println(tiger instanceof Animal); // true
        System.out.println(tiger instanceof Tiger); // true
    }
}

class Animal {

}

class Tiger extends Animal {

}

 

이러한 instanceof 연산자는 클래스들의 상속 관계뿐만 아니라 인터페이스의 구현 관계에서도 동일하게 적용된다.

 

class Main {

    public static void main(String[] args) {
        Token accessToken = new AccessToken();
        System.out.println(accessToken instanceof Token); // true
        System.out.println(accessToken instanceof AccessToken); // true
        System.out.println(accessToken instanceof Session); // false

    }
}

interface Token {

}

class AccessToken implements Token {

}

interface Session {

}

 

대입(=) 연산자

대입 연산자는 변수와 같은 저장공간에 값 또는 수식의 연산결과를 저장하는데 사용되며, 저장된 값을 연산결과로 반환한다. 아울러 연산자들 중에서 가장 낮은 우선순위를 가지고 있어 가장 나중에 수행된다. 다음과 같이 복합 대입 연산자로서도 활용할 수 있다.

 

        int count = 10;
        System.out.println(count += 10); // 20
        System.out.println(count *= 5 + 5); // 200 // count = count * (5 + 5)와 동일

 

화살표(->) 연산자

화살표(->) 연산자는 람다식(익명 함수)을 작성할 때 사용된다. 람다식이란 함수형 인터페이스를 구현한 익명 클래스의 객체로서 별도로 인터페이스를 구현한 내부 익명 클래스의 객체 생성할 필요 없이 람다를 이용해 해당 인터페이스의 메서드에 대해 한 줄로 간단히 표현한 식이다. 마치 함수처럼 작성을 하지만 실제로는 익명 클래스의 객체를 생성하는 방식으로 동작한다.

 

람다식이 등장하기 전에는 인터페이스를 사용하기 위해 다음과 같이 메서드 내에 내부 익명 클래스의 객체를 생성하여 이를 통해 인터페이스를 구현한 메서드를 사용하였다.

 

class Main {

    public static void main(String[] args) {
        Token token = new Token() {
            @Override
            public void create() {
                System.out.println("CREATE!!");
            }
        };

        token.create();
    }
}

interface Token {
    void create();
}

 

하지만 람다식을 이용하면 다음과 같이 한 줄로 간단하게 나타낼 수 있다.

 

class Main {

    public static void main(String[] args) {
        Token token = () -> System.out.println("CREATE!!");

        token.create();
    }
}

interface Token {
    void create();
}

 

더욱이 이러한 람다식은 메서드의 매개변수로 전달하거나 메서드의 결과로 반환하는 것이 가능하다. 메서드를 마치 변수처럼 다루는 것이 가능해진 것이다.

 

3항 연산자

3항 연산자는 조건식을 포함한 세 개의 피연산자를 필요로하는 연산자로 if - else 문의 역할을 한 줄로 표현해줄 수 있다. 조건식이 true일 경우에는 첫 번째 값(or 식)이 반환되며, false일 경우에는 두 번째 값(or 식)이 반환된다.

 

    char grade = 'A';
    System.out.println(grade == 'A' ? "GOOD" : "BAD"); // GOOD

    grade = 'B';
    System.out.println(grade == 'A' ? "GOOD" : "BAD"); // BAD

 

 

연산자의 우선 순위와 결합 규칙

연산자의 우선 순위

식에 사용된 연산자가 둘 이상인 경우, 연산자의 우선순위에 의해 연산 순서가 결정되는데, 우선순위가 높은 연산자가 낮은 연산자 보다 먼저 처리된다. 이에 대한 대표적인 사례들을 살펴보자.

 

우선 단항 연산자는 이항 연산자 보다 우선순위가 높다. 예를 들면 다음과 같은 식이 있을 때 cancelCount 변수 앞 단항 연산자(-)가 이항연산자(+) 보다 먼저 적용되기 때문에 totalCount의 연산 결과값은 -13이 아닌 7이 나온다. 참고로 이항 연산자는 삼항 연산자 보다 우선순위가 높다.

 

    int totalCount = 0;
    int orderCount = 10;
    int cancelCount = 3;
    totalCount = -cancelCount + orderCount;
    System.out.println(totalCount); // 7

대입 연산자(=)는 연산자 중에 우선순위가 제일 낮다.

 

또한 곱셈과 나눗셈은 덧셈과 뺄셈보다 우선순위가 높다. 이는 사실 수학과 동일한 부분이다. 아래 연산의 결과는 150이 아닌 105가 나오게 된다.

 

    System.out.println(5 + 10 * 10); // 105

 

산술 연산자는 비교 연산자 보다 우선순위가 높다. 아래 결과는 가장 먼저 > 보다 5 + 5와 3 + 3 연산이 먼저 일어나서 10과 6을 비교하게 되고 true를 반환한다.

 

    System.out.println(5 + 5 > 3 + 3); // true

 

비교 연산자는 논리 연산자 보다 우선순위가 높다. 아래 결과는 5 > 3 비교 연산에 의한 true를, 3 == 2 비교 연산에 의한 false를 가장 마지막에 논리 연산자 ||에 의해 처리된 결과인 true를 반환하게 된다.

 

    System.out.println(5 > 3 || 3 == 2); // true

※ 참고로 논리 연산자가 여러 개 있을 때는 AND를 의미하는 &와 &&가 OR를 의미하는 |와 || 보다 우선순위가 높다는 점에 유의하자

 

정리해보면 다음과 같다.

 

1. 우선순위 : 산술 연산자 > 비교 연산자 > 논리 연산자 > 대입 연산자

2. 다항 연산자간 우선순위 : 단항 연산자 > 이항 연산자 > 삼항 연산자

 

연산자의 결합규칙

하나의 식에 여러 개의 연산자가 있는데 우선순위가 같을 때는 어떻게 처리될까? 정답은 왼쪽에서 오른쪽의 순서로 연산을 수행한다. 다만, 단항 연산자나 대입 연산자오른쪽에서 왼쪽 순서로 연산을 수행한다.

 

예를 들어, 다음과 같이 우선순위가 같은 + 연산자와 - 연산자로 구성된 식에서는 왼쪽에서 오른쪽 순서대로 연산을 수행한다.

 

    System.out.println(5 + 5 - 3 - 3); // 4 (10 -> 7 -> 4)
    System.out.println(5 - 3 - 3 + 5); // 4 (2 -> -1 -> 4)

 

반면에, 대입 연산자로만 구성된 식에서는 오른쪽에서 왼쪽 순서대로 연산을 수행하므로 아래 결과는 최초 orderCount 변수에 3이 할당되고 이 값이 반환되어 이후에 totalCount에도 3이 할당되게 된다.(마찬가지로 이로 인해 다시 3 반환)

 

    int totalCount;
    int orderCount;
    totalCount = orderCount = 3;
    System.out.println(totalCount); // 3
    System.out.println(orderCount); // 3

 

참고 자료

  • 도우출판 "자바의 정석"
  • 위키북스 "스프링 입문을 위한 자바 객체 지향의 원리와 이해"
  • https://catch-me-java.tistory.com/31