[Advanced C++] 56. operator overloading (산술, I/O operators), std::cin.setstate(std::ios_base::failbit)

dev.kelvin·2025년 5월 27일
1

Advanced C++

목록 보기
56/74
post-thumbnail

1. operator overloading

연산자 오버로딩

C++에서 연산자는 함수로 구현된다, 이전에 정리했던 함수 오버로딩을 연산자 함수에 적용함으로써 다양한 데이터 타입에 작동하는 연산자 버전을 정의할 수 있다

이렇게 함수 오버로딩을 사용해서 연산자를 오버로딩 하는걸 연산자 오버로딩이라고 한다

사용자 정의 타입을 operator+()에 적용시키면 어떨까?

    Mystring string1 { "My Name" };
    Mystring string2 { "Kelvin" };
    std::cout << string1 + string2 << '\n';

결과가 My Name Kelvin으로 나올것 같지만 그렇지 않고 컴파일 에러가 발생한다, 왜냐하면 Mystring이라는 사용자 정의 타입끼리의 +연산자 오버로딩이 없기 때문이다, 따라서 위 연산을 처리하려면 사용자 정의 타입끼리의 연산자 오버로딩을 구현해야 한다

연산자가 있는 표현식을 컴파일러가 평가할 때 다음과 같은 규칙을 적용한다

  1. 모든 피연산자가 기본 데이터 타입이면 컴파일러는 존재하는 내장 루틴(built in routine)을 호출한다, 만약 존재하지 않으면 컴파일 에러를 발생시킨다

  2. 하나라도 피연산자가 사용자 정의 타입이라면 컴파일러는 함수 오버로드 결정 알고리즘을 사용하여 사용자가 정의한 오버로드된 연산자 함수 중에서 가장 적합한 것을 찾는다

이 과정에서 필요하다면 하나 이상의 피연산자를 오버로드 된 연산자의 매개변수 타입과 일치시키기 위해 암시적 변환을 할 수 있다

	Mystring + int; //이걸 처리하기 위해
    Mystring + Mystring; //int를 Mystring타입으로 변환하는 생성자가 있다면 그 생성자를 사용한다
    
    //혹은 반대로 사용자 정의 타입을 기본 타입으로 변환하는 타입 캐스트 연산자가 오버로드 되어있다면 사용자 정의 타입을 기본 타입으로 변환하여 기본 타입 연산자를 사용하려고 시도할 수 있다

위 내용에서 적합한것을 찾지 못한다면 컴파일 에러를 발생시킨다

연산자 오버로딩의 제한

  1. C++에서 거의 모든 기존 연산자는 오버로드될 수 있다, 여기서 예외인 연산자가 있는데 바로 ?:(삼항연산자), sizeof, ::(범위 지정 연산자), .(멤버 접근 연산자), .*(멤버 포인터 접근 연산자), typeid(타입 정보 연산자), 캐스팅 연산자(static_cast, dynamic_cast, const_cast, reinterpret_cast)는 오버로드 될 수 없다

  2. 새로운 기호를 연산자로 만들거나 기존 연산자의 이름을 변경할 수 없다
    ex) 지수 표현을 위해 operator**은 불가능함

  3. 오버로드 된 연산자의 피연산자는 적어도 하나는 사용자 정의 타입이어야 한다
    operator+(int, Mystring)은 가능하지만 operator+(int, double)은 불가능하다

이때 STL 클래스는 사용자 정의 타입이기 때문에 operator+(int, std::string)은 오버로딩이 가능하다, 하지만 추후에 C++표준으로 다음과 같은 오버로딩이 나와 버그를 발생시킬 수 있기 때문에 사용하지 않는걸 권장한다

  1. 피연산자의 개수를 변경할 수 없다 ex) operator-()를 오버로딩할 때 단항으로 변경이 불가능하다

  2. 모든 연산자는 우선순위와 결합성(같은 우선순위를 가진 연산자가 나열되어 있을때 어떤 순서로 수행될지)을 유지하고 변경할 수 없다

따라서 연산자 오버로딩 시 기존 연산자의 의도를 유지하는것이 좋다 (operator-()를 오버로딩 할 때 -의도를 유지하는것과 같다)

  1. 오버로드된 연산자는 원래 연산자와 같은 방식으로 값을 return해야 한다

산술연산자와 같이 피연산자를 수정하지 않는 연산자들은 return by value로 반환하고 왼쪽 피연산자를 수정하는 대입 연산자나 전위 증감 연산자와 같은 연산자는 return by ref로 반환해야 한다 (연쇄적인 할당이 가능해짐)

friend 함수를 사용한 연산자 오버로딩

    class Foo
    {
    public:
        Foo(int value) : m_value{ value } {}

        int getValue() const { return m_value; }

    private:
        int m_value{};
    };

    int main() 
    {
        Foo f1{ 10 };
        Foo f2{ 20 };

        f1 + f2;

        return 0;
    }

위 코드는 사용자 정의 타입 Foo 클래스에 operator+()를 오버로딩하지 않았기 때문에 컴파일 에러가 발생한다

friend함수를 사용하여 연산자 오버로딩을 하면 다음과 같다

    class Foo
    {
    public:
        Foo(int value) : m_value{ value } {}

        friend Foo operator+(const Foo& lhs, const Foo& rhs);

        int getValue() const { return m_value; }

    private:
        int m_value{};
    };

    Foo operator+(const Foo& lhs, const Foo& rhs) //피연산자로 Foo타입 객체 2개를 참조로 받는다
    {
    	//피연산자로 받은 Foo타입 객체의 멤버변수끼리 더하고 Foo타입 임시객체를 생성해서 값으로 반환한다
        return Foo{ lhs.m_value + rhs.m_value }; //friend함수이기 때문에 private:멤버 변수에 접근이 가능
        
        //이떄 아래처럼 암시적 변환도 가능함 (생성자가 구현되었기 때문, explicit이라면 불가능)
        return lhs.m_value + rhs.m_value;
    }

    int main() 
    {
        Foo f1{ 10 };
        Foo f2{ 20 };

        Foo f3{ f1 + f2 }; //operator+()연산자 오버로딩이 되었기 때문에 가능

        std::cout << f3.getValue() << std::endl; // Output: 30

        return 0;
    }

operator-()도 같은 방식으로 처리하면 된다

	Foo operator-(const Foo& lhs, const Foo& rhs)
    {
        return Foo{ lhs.m_value - rhs.m_value };
    }

물론 friend함수는 클래스 내부에서 정의될 수 있다, 하지만 멤버 함수는 아니게 된다 (friend operator overloading도 class내부에서 가능)

그렇다면 굳이 왜 friend를 사용해서 연산자 오버로딩을 할까?

멤버함수로 operator를 오버로딩하게 되면 연산자를 전부 매개변수에 넣을 수 없다

operator+와 같은 경우에는 2개의 매개변수를 가지는데 멤버 함수는 기본적으로 맨 앞에 this포인터 매개변수를 갖기 때문에 총 3개가 되기 때문이다, 이러한 문제로 교환법칙이 성립하지 않게 된다
(왼쪽 피연산자가 반드시 해당 클래스의 객체여야만 호출이 가능하다)

	//str.operator+("Hello"); 멤버함수로 operator오버로딩 시 이런식으로 호출해야하기 때문
    
	str + "Hello" (O)
    "Hello" + str(X)

만약 연산자 오버로딩에서 피연산자끼리 다른 타입을 적용하고 싶다면 연산자 오버로딩 정의에서 매개변수를 서로 다른 타입으로 지정하면 된다

    class Foo
    {
    public:
        Foo(int value) : m_value{ value } {}

		//서로 다른 피연산자를 받은 연산자 오버로딩 함수
        friend Foo operator+(const Foo& lhs, int rhs);

        int getValue() const { return m_value; }

    private:
        int m_value{};
    };

    Foo operator+(const Foo& lhs, int rhs)
    {
        return lhs.m_value + rhs;
    }

    int main() 
    {
        Foo f1{ 10 };
        Foo f2{ 20 };

        Foo f3{ f1 + 100 }; //다른 타입끼리의 연산을 지원하게 되었다

        std::cout << f3.getValue() << std::endl;

        return 0;
    }

연산자 오버로딩에서 피연산자의 순서는 실제 연산할때의 순서와 동일해야 한다
ex) operator+(const Foo& InFoo, int InInt);면 f1 + 100이어야지 100 + f1이면 안된다는 의미

또한 연산자 오버로딩은 함수 오버로딩이기에 여러버전의 동일한 연산자를 오버로딩 할 수 있다

    class Foo
    {
    public:
        Foo(int value) : m_value{ value } {}

        friend Foo operator+(const Foo& lhs, int rhs);
        friend Foo operator+(int lhs, const Foo& rhs);
        friend Foo operator+(const Foo& lhs, const Foo& rhs);

        int getValue() const { return m_value; }

    private:
        int m_value{};
    };

    Foo operator+(const Foo& lhs, int rhs)
    {
        return lhs.m_value + rhs;
    }

    Foo operator+(int lhs, const Foo& rhs)
    {
	    return rhs + lhs; //operator+(const Foo& lhs, int rhs)이걸 이용, 이런 방식이 중복을 최소화 시킬 수 있다 (복잡해지면)
    }

    Foo operator+(const Foo& lhs, const Foo& rhs)
    {
        return lhs.m_value + rhs.m_value;
    }

    int main() 
    {
        Foo f1{ 10 };
        Foo f2{ 20 };

        Foo f3{ 100 + f1 };
        Foo f4{ f1 + 100 };

        Foo f5{ f1 + 100 + f1 }; //f1 + 100으로 Foo타입 임시객체 생성 후 + f1이 된다

        std::cout << f3.getValue() << std::endl;

        return 0;
    }

다른 오버로딩 된 연산자를 호출하여 연산자 오버로딩을 정의할 수 있다

    Foo operator+(int lhs, const Foo& rhs)
    {
	    return rhs + lhs; //operator+(const Foo& lhs, int rhs)이걸 이용, 이런 방식이 중복을 최소화 시킬 수 있다 (복잡해지면)
    }

코드 중복이 감소되고 유지보수성이 향상된다, 단 무한 재귀에 빠지지 않도록 주의해야 한다

일반 함수를 사용한 연산자 오버로딩 (friend함수X)

사용자 정의 타입의 private:멤버에 접근할 필요가 없다면 일반 함수를 사용하여 연산자 오버로딩을 정의하면 된다 (혹은 getter를 사용한다면)

    class Foo
    {
    public:
        Foo(int value) : m_value{ value } {}

        int getValue() const { return m_value; }

    private:
        int m_value{};
    };

    Foo operator+(const Foo& lhs, int rhs)
    {
        return Foo{ lhs.getValue() + rhs };
    }

    int main() 
    {
        Foo f1{ 10 };
        Foo f2{ 20 };

        Foo f4{ f1 + 100 };

        return 0;
    }

단 friend 선언은 함수의 프로토타입 역할도 하기 때문에 직접 프로토타입을 제공할 필요가 없지만 friend가 아닌 일반 함수를 비멤버로 오버로딩에 사용한다면 프로토타입을 제공해야 한다

	//.h
    
    class Foo
    {
    public:
        Foo(int value) : m_value{ value } {}

        int getValue() const { return m_value; }

    private:
        int m_value{};
    };

    Foo operator+(const Foo& lhs, int rhs);
    
    //.cpp
    Foo operator+(const Foo& lhs, int rhs)
    {
        return Foo{ lhs.getValue() + rhs };
    }

일반적으로 일반 함수로 오버로딩이 가능하다면 friend함수를 이용한 오버로딩보다 일반함수를 사용한 오버로딩을 더 권장한다 (단 무리해서 getter를 만들지는 말자, 추가적인 함수 구현이 없을때만)

overloading I/O operators

다음과 같은 클래스가 있고 멤버 데이터를 전부 출력한다고 가정해보자

    class Foo
    {
    public:
        Foo(int inValue1, int inValue2, int inValue3) : value1{ inValue1 }, value2{ inValue2 }, value3{ inValue3 } {}

    private:
        int value1{};
        int value2{};
        int value3{};
    };

value1,2,3에 대한 모든 getter()를 만들고 std::cout << 에 하나하나 출력하는 방법이 있을것이고 Foo클래스 내부에 print()를 만들어 멤버 데이터를 전부 출력하는 방법이 있을것이다

이러한 방법은 쓸모없는 함수를 만들 가능성이 크고 print()와 같은 함수는 void를 return하기 때문에 std::cout<<에 사용이 불가능하다

이럴때 operator<<를 오버로딩하여 쉽게 처리할 수 있다

operator<<는 산술연산자와 같이 이항 연산자이다, 하지만 매개변수 타입이 다르다

	Foo f1{ 10, 20, 30 };
	std::cout << f1;

operator<<의 좌측 피연산자는 std::ostream타입의 객체인 std::cout이고 우측 피연산자는 Foo클래스 타입의 객체인 f1이 된다

따라서 다음과 같이 오버로딩 하면 된다

	class Foo
    {
    public:
        Foo(int inValue1, int inValue2, int inValue3) : value1{ inValue1 }, value2{ inValue2 }, value3{ inValue3 } {}

        friend std::ostream& operator<<(std::ostream& os, const Foo& foo)
        {
            return os << foo.value1 << ", " << foo.value2 << ", " << foo.value3;
        }

    private:
        int value1{};
        int value2{};
        int value3{};
    };

    int main() 
    {
        Foo f1{ 10, 20, 30 };

        std::cout << f1;

        return 0;
    }

이때 반환타입이 std::ostream의 &로 되어있는걸 주의해야 한다

산술연산과 같은 경우에는 단순히 연산된 새로운 결과를 생성하고 반환하기 때문에 값으로 반환했지만 operator<<는 다르다, std::ostream은 값으로 반환하려고 하면 컴파일 에러가 발생한다 (std::ostream은 복사를 금지하기 때문)

따라서 참조로 반환을 하는데 이는 복사를 방지할 뿐 아니라 그 뒤의 << 출력을 연결할 수 있게 해준다

	std::cout << f1 << '\n'; //std::ostream&으로 반환하기 때문에 이어서 << '\n'이 가능한 것
	
    Foo f1{ 10, 20, 30 };
	Foo f2{ 40, 50, 60 };
    std::cout << f1 << f2;

만약 operator<<가 void를 반환한다면 (std::cout << f1) << '\n'이 될 것이고 결국 void << '\n'이 되어 아무런 의미를 갖지 못하게 된다

operator<<뿐 아니라 체인해서 결과값을 계속해서 사용해야 한다면 값이 아닌 참조타입으로 반환하는게 좋다

이때 참조 타입으로 반환하는게 위험하지 않은가? 라는 의문이 있을 수 있지만 해당 연산자 함수의 호출이 종료될 때 까지 무조건 왼쪽 피연산자는 존재하기 때문에 걱정할 필요가 없다

그렇다면 operator>>는 어떻게 오버로딩할까?

operator<<와 비슷한 방식으로 진행한다

	friend std::istream& operator>>(std::istream& is, Foo& foo) //이때 Foo는 수정가능하게 const가 아니어야 한다
	{
		is >> foo.value1 >> foo.value2 >> foo.value3;

		return is;
	}

ostream이 아닌 std::istream&을 받고 return도 std::istream&으로 해준다

	Foo f1{1, 2, 3};
	Foo f2{4, 5, 6};
    std::cin >> f1 >> f2; //입력 받은 값으로 f1, f2가 초기화 된다

단 이때 사용자가 유효하지 않은 데이터를 입력했을 때 문제가 발생할 수 있다

    Foo f1{1, 2, 3};
	std::cin >> f1; //4b, 5, 6을 입력했다고 가정

int값이 아닌 이상한 값을 넣어서 결과가 4,0,3이 나오게 되었다

이때 4b, 5, 6을 넣게 되면 4가 먼저 들어가고 입력 버퍼에는 b 5 6남게 된다, b는 int타입으로 변환이 안되기 때문에 0으로 들어가게 되고 입력 스트림이 fail되었기 때문에 남아있는 값인 3이 들어가게 된 것이다

이러한 현상을 부분 추출(Partial Exctraction)이라고 한다

이런 부분 추출을 방지하기 위해선 어떻게 해야할까?

연산을 transactional 하게 만드는 방법이 있다, transactional 연산은 완전히 성공하거나 완전히 실패하는 두 케이스만 존재한다, 일부만 성공 일부만 실패는 허용되지 않는다 (all or nothing)

if()로 입력스트림이 정상적으로 처리되었는지 확인하고 정상이라면 값을 덮어씌우고 그렇지 않다면 덮어씌우지 않는 방식으로 구현할 수 있다

    std::istream& operator>>(std::istream& is, Foo& foo)
    {
        int a{}, b{}, c{};
        if (is >> a >> b >> c)
        {
            foo = Foo{ a, b, c };
        }

        return is;
    }

이는 다음과 동일한 동작을 한다

	std::istream& operator>>(std::istream& is, Foo& foo)
    {
        int a{}, b{}, c{};
        is >> a >> b >> c;

        if (is) //istream을 조건으로 사용
        {
            foo = Foo{ a, b, c };
        }

        return is;
    }

이제 4b 5 6을 입력하면 istream이 fail되어 아무런 갚도 덮어씌우지 않고 초기값이 그대로 남아있게 되고 4 5 6 을 정상적으로 입력하면 값이 입력받은 값으로 잘 초기화 된다

혹은 istream이 fail되었을 때 기본 상태로 재설정하는 방식으로도 만들 수 있다

	std::istream& operator>>(std::istream& is, Foo& foo)
    {
        int a{}, b{}, c{};
        is >> a >> b >> c;

        foo = is ? Foo{ a, b, c } : Foo{}; //fail하면 기본생성자로 객체 생성하여 기본 상태로 재설정

        return is;
    }

이렇게 transactional연산은 다양한 전략을 사용하여 구현이 가능하다

  • 성공 시 변경 (전부 다 성공하면 변경, 실패 시 아무것도 변경 안함)
  • 실패 시 복원 (원본을 복사해놓고 실패하면 원본으로 복구)
  • 실패 시 롤백 (하나하나 하다가 실패하면 다시 작업 단계를 거꾸로 밟아 취소해서 원래대로 만드는 방식)

하지만 std::cin이 값을 입력을 받았지만 의미상 유효하지 않을경우가 있다 ex) 분모에 사용해야 하는데 0을 입력 받았을 때

이럴때는 operator>>가 입력받은 값 중 의미상 유효하지 않은것이 있는지 판단하고 수동으로 istream을 fail로 만들어주는 방법을 사용해야 한다

	std::cin.setstate(std::ios_base::failbit);
    std::istream& operator>>(std::istream& is, Foo& foo)
    {
        int a{}, b{}, c{};
        is >> a >> b >> c;

        if (is)
        {
            if (a < 0 || b < 0 || c < 0)
            {
                is.setstate(std::ios::failbit);
                return is;
            }

            foo = Foo{ a, b, c };

            return is;
        }

        return is;
    }

이렇게 다양한 연산자를 overloading하여 프로그래머의 필요에 맞게 사용할 수 있다

profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글