C언어

C++ 프로그래밍 - 클래스의 기초

머리큰개발자 2021. 5. 5. 17:47
구조체 vs 클래스

C에서 구조체는 많이 사용해봤을 것이다.

구조체 내의 변수와 함수는 멤버 변수와 멤버 함수라고 명칭한다.

C++에서는 구조체 안에 함수를 선언하는 것이 가능해졌으며, .연산자를 통해 구조체의 멤버 변수와 함수에 접근할 수 있다.

Class 또한 마찬가지이다. 

namespace때와 마찬가지로, 함수는 클래스 내에 선언만하고 정의는 클래스 외부에서 할 수 있으며, 같은 공간안에 선언된 멤버끼리는 서로 .연산자 없이 접근하여 사용할 수 있다.

 

그렇다면 구조체와 클래스가 다른 점은 대체 무엇일까?

 

현재 최신 컴파일러로 오면서 그 차이점은 더더욱 줄어들었다.

과거에는 구조체와 달리 클래스의 멤버 변수들은 초기화하려면 반드시 member initializer 나 constructor를 써야했다.

이 둘은 밑에서 알아보도록 한다.

하지만 c++11 이후에는 멤버 변수를 선언과 동시에 초기화 할 수 있게 업그레이드 되었다.

C++11 Language Extensions – Classes, C++ FAQ (isocpp.org)(참고)

 

이제 둘의 유일한 차이점은 접근제어 지시자(레이블) 뿐이다.

 

구조체는 접근제어 지시자를 사용하여 선언할 수 있지만 항상 public으로 취급된다.

(멤버 함수가 구조체 안에 정의될 경우 기본적으로 inline 함수로 선언된다. (인라인 함수는 항상 선언과 정의가 같은 파일안에 들어있어야 한다. 주의))

그에 반해 클래스는 기본적으로 private로 취급된다. 

#include <iostream>
using namespace std;

struct s {
	int x = 0, y = 1;
	void change() {
		x = 1; y = 0;
	}
	void print() {
		cout << x << y << "\n";
	}
};

class c {
	int x = 0, y = 1;//과거에는 불가능
	string s = "asdf";//과거에는 불가능
public:
	void change() {
		x = 1; y = 0;
	}
	void print() {
		cout << x << y <<s<< "\n";
	}
};

int main() {
	struct s s1;
	s1.change();
	s1.print();
	cout << s1.x << "\n";

	class c c1;
	c1.change();
	c1.print();
	//cout << c1.x << "\n"; //접근 불가

}

메인 함수를 보면, s1.x에 직접 접근하여 출력을 할 수 있다.

하지만 class인 c1는 멤버 변수에 접근할 수 없다. 

이것이 구조체와 클래스의 차이점이라 보면 된다.

 

또한 주석 달아놓은 것을 보면, 과거에는 int x=0, y=1; 등의 초기화가 불가능했었는데, 코드의 양을 줄일 수 있다는 장점이 있기 때문에 최신 컴파일러에선 기능이 추가되었다.

하지만, 소속 회사 등이 예산등의 문제로 옛날 컴파일러를 사용한다면, 멤버 이니셜라이저(const도 초기화 가능)와 constructor 를 통해 초기화하도록 하자. (컴파일 자체가 안된다.)

 

접근제어 지시자

접근제어 지시자는 객체지향 프로그래밍에서는 필수적인 요소이다.

그 의미는 저번 글에서 얘기했으므로 넘어가자.

C++에서는 자바와 다르게 4가지가 아닌 3가지를 제공한다.

 

public : 어느 곳에서나 접근 가능한 전역 변수 같은 존재

protected : 상속 받은 클래스에서만 사용할 수 있도록 제어

private : 나만 쓸거야! 내 클래스에서만 접근 가능

 

private인 경우 멤버 함수를 통해서만 접근이 가능하므로, 외부에서 접근할 수 있는 방법을 제한할 때 사용한다.

물론 기본적으로 private이 사용되며 그래야만 abusing을 막을 수 있다.

 

클래스의 필요성

그래서, 왜 굳이 구조체를 사용하지 않고 클래스를 사용하여 프로그래밍을 하는가?

 

접근제어 지시자와 객체의 관계를 이해하기 위해서 정의부터 생각해보자.

 

객체지향의 '객체'의 정의부터 해보자.

객체란 Object를 번역한 말로, 나는 사실 물건과 다를바 없다고 생각한다.

 

어떤 물건을 봤을 때 우리는 항상 생각한다. "뭐에 쓰는 물건인고?" 

어디에다 쓰는지 기능을 파악했다면 다음으로 궁금한 것은 "어떻게 만든거여?" 가 된다.

그리고 어린시절이었다면 필연적으로 물건을 분해하여 어떻게 만들었는지 살펴봤을 것이다.

그러고 나면 그 물건은 거의 완벽히 파악했다고 할 수 있을 것이다.

이것이 물건이자 객체이다.

 

즉, 객체는 그 기능과 재료 = 행동과 정보 = 함수와 변수로 볼 수 있다.

 

조금 더 컴퓨터스럽게 접근하면, 객체란 함수와 변수를 가진 어떤 것이 메모리 상에 실재하는 것을 의미한다.

그러므로 클래스를 선언한 것은 객체를 위한 틀을 만들었다고 볼 수 있고, 실제로 변수처럼 선언하거나 동적으로 할당을 받아야 객체를 생성했다고 할 수 있겠다.

이는 철학적으로 보면, 아주 이상적인 하나의 이데아가 있고 현실에 존재하는 실체들로 비유할 수도 있을..까?

다행스럽게도 c++은 c에서 사용하던 그대로 모두 사용할 수 있고, 변수처럼 선언하는 방식도 (자바와 다르게)지원한다!

편하게 쓰도록 하자!

 

객체가 무엇인지 알았으니 클래스와 연관시켜보자.

 

클래스와 구조체의 차이점은 접근제어 지시자의 유무라고 했었다.

그렇다면 접근제어 지시자가 객체지향의 핵심적인 역할을 맡고 있다고 봐도 무방할 것인데, 그 이유 2가지를 살펴보자.

 

 

1. 정보 은닉

첫 번째로는 정보를 외부로부터 감출 수 있다는 점이다. 

 

만약 태엽시계가 있다고 가정해보자.

시계를 작동시키려면 태엽을 감아주어 동력을 제공해주어야 한다.

즉, '태엽을 감는다'는 동작을 통해서만 내부와 소통할 수 있게 만들어 준 것이다.

만약 태엽을 감지 않고 마음대로 시계를 뜯어서 스프링과 톱니바퀴를 건든다면 시계는 틀림없이 망가지거나

시계가 반대로 가는 등 의도치 않은 결과를 만들어 낼 것이다.

이런 잡다한 오류와 실수를 방지하기 위해 특정 방법으로만 내부와 소통할 수 있게 만들어 놓는 것을 정보 은닉이라고 한다.

또한 태엽을 반대로 감는다면 감겨지는 느낌이 안난다던지, 이상한 소리가 난다던지 하는 즉각적인 피드백을 통해

아 내가 지금 태엽을 잘못 감고 있구나를 알 수 있게 도와준다.

이를 통해 나의 실수를 빠르게 파악할 수 있도록 도와주는 역할 또한 한다.

 

그러므로 외부에서 알 필요가 없고 직접 접근할 필요가 없는 변수들은 private로 선언해두고 public 함수를 제공하여 내부 변수들에 접근할 수 있도록 해주는 것이 기본적인 구조이다.

 

만약 멤버 함수를 사용하는데 멤버 변수의 값을 전혀 일절 바꾸지 않는다면, 그 함수 뒤에 const를 선언해줘야한다.

만약 선언하지 않았더라도 당장 컴파일러가 에러를 내는 것은 아니다. 

하지만 명시적으로 선언해놓아야 하는 이유는 프로그램의 안전성 때문이다.

주의해야할 것은 const 함수 내부에서는 const 함수만을 호출할 수 있다.

또한 const를 매개변수로 사용한다면, 그 매개변수는 const가 선언된 함수만을 호출할 수 있다.

 

#include <iostream>
using namespace std;

class c {
	int a = 10;
public:
	void print(int b) {
		change();
		cout << " a : " << a << " b : " << b << "\n";
	}
	void change() {
		a += 10;
	}
};


int main() {
	c c1;
	c1.print(20);
}

가령 위의 코드는 아무 문제가 없다.

print(int b)함수만을 보면 값을 변화시키지 않으므로 const 를 선언할 수 있다.(실제로 바꾸는 것은 change함수)

하지만 const를 선언할 경우 change()를 호출할 때 컴파일 에러가 발생한다. 

이는 change가 const함수가 아니기 때문이고, 실제로 change를 호출하면 값이 바뀌기 때문임을 눈치챌 수 있다.

 

그렇다면 change를 쓰기 위해 change() const로 선언하면 어떤가?

이번엔 a+=10; 에 에러가 뜰 것이다. 이는 멤버 변수를 변경시키는 행동을 하기 때문에,

컴파일 타임에 이미 에러가 발생한다. 

 

(참고로 const를 함수 끝에 붙일 시에 오버로딩 할 수 있다.)

 

 

2. 캡슐화

두 번째로는 캡슐처럼 만들어서 사용할 수 있다는 점이다.

캡슐화는 기본적으로 정보은닉이 포함된 개념이다.

 

알약같은 것을 생각하면 되는데, 외부에서 봤을 때 캡슐은 내부에 뭐가 들었는지 파악하기 어렵기 때문이다.

하지만 알약 속에 뭐가 들었는지는 중요하지 않다.

우리는 이 알약을 먹었을 때 어떤 효과가 있는지, 언제든지 들고다니면서 먹을 수 있는지가 더 중요하기 때문이다. 

실제로 알약 내부를 신경쓰는 사람들은 약사밖에 없다는 것을 알고 있다면, 캡슐화가 사용자에게 어떤 의미를 주는지 알기 쉽다.

사용자들은 그냥 가지고 다니면서 편리하게 사용하기만 하면 되기 때문에,

캡슐화는 필요한 기능만을 제공하고,

똑같은 기능을 여러번 사용하기 쉽게 만들어주면 된다!

 

 

이제 클래스에 대한 필요성과 객체에 대한 개념을 얼추 알았으니 자세한 설계로 들어가보자.

 

 

생성자와 소멸자 ( Cosntructor & Destructor)

위에서 한 클래스의 선언과 정의는 이제 막 틀을 만든 단계라고 보면 된다.

도자기 틀이 있다고 해서 도자기를 만든 것이 아니고, 원료를 붓고 열을 가해야 도자기를 만들 수 있는 것처럼

클래스를 실제 객체로 생성할 때는 메모리 상에 할당해주고, 그 후 '생성자'를 호출하여 객체를 초기화해준다.

(할당 후 생성자가 호출되는 순서임)

 

생성자는 멤버 함수로 우리가 직접 선언할 수 있으며, 반환 타입이 없고 실제로 반환하지도 않는다. 또한 클래스의 이름과 동일한 이름을 가져야하며, 객체가 생성될 때 단 한 번만 호출되고 종료된다.

 

생성자 또한 함수이므로 오버로딩(overloading)이 가능하고, 오버로딩 할 때 주의점과 방식이 완전히 동일하므로, 모호하지 않고 내가 어떻게 쓸 것인지를 반드시 정하고 만들자.

해당하는 생성자가 없을 경우 컴파일 에러가 발생한다.

 

생성자가 호출될 때는 주로 멤버 변수들을 초기화하는 역할을 한다. 

만약 멤버 변수 중 다른 클래스가 있다면, 그 클래스의 생성자를 호출하여 새롭게 객체를 만들 수도 있다.

멤버 이니셜라이저도 이 역할을 한다.

멤버 이니셜라이저는 성능 향상에 도움이 되고, const 변수도 초기화 할 수 있다는 점에서 그 결이 조금 다르다.

이것이 가능한 이유는 멤버 이니셜라이저는 내부적으로는 선언과 동시에 초기화가 이루어지는 형태로 작동하기 때문인데, 몸체 부분에서 초기화를 진행한다면 그렇지 않기 때문이다.

 

예를 들어 멤버 이니셜라이저는 a(b) 식으로 사용한다면 int a=b; 의 선언과 초기화가 동시에 이루어지게 된다.

하지만 몸체 부분에서 a=b; 로 초기화한다면 int a; a=b; 로 선언과 초기화가 따로 이뤄지기 때문에 성능에서 차이가 발생하게 된다는 것이다.

이는 이 일이 자주 발생할수록, 클래스의 덩치가 클수록 성능의 차이가 커질 수 있다.

 

멤버 이니셜라이저의 사용법은 생성자의 ()연산자 뒤에 : 변수명(초기화값) 의 방식으로 사용한다.

 

#include <iostream>
using namespace std;

class co2D {
	int x, y;
	const int size;//const 상수
public:
	co2D(const int& a, const int& b) : size(1) { // const 1로 초기화
		x = a;//멤버변수 초기화
		y = b;
	}
	int getX() {
		return x;
	}
	int getY() {
		return y;
	}
};

class co3D {
	co2D co;
	int z;

public:
	co3D(const int& a, const int& b, const int & c) : co(a,b) { //co2D 생성자 호출
		z = c;
	}
	void print() {
		cout << " x : " << co.getX()<< " y : " << co.getY() << " z : " << z << "\n";
	}

};

int main() {
	co3D *c = new co3D(1, 2, 3);
	c->print();
}

co3D안에는 cd2D가 들어있다. 

main 에서 co3D를 동적할당 받을 때, 1,2,3 을 이용하여 생성자를 호출하며, co3D의 생성자가 호출될 때 co(a,b) 생성자가 멤버 이니셜라이저에 의해 호출되어 생성된다. 

co2D에는 const int size가 있는데, 이는 멤버 이니셜라이저를 통해서 1로 초기화 되는 것을 볼 수 있다.

즉, 메모리 할당 -> 멤버 이니셜라이저 -> 생성자 메인코드 순으로 진행되는 것을 확인할 수 있다.

 

만약 배열을 할당받아 사용하고 싶다면, 애초에 배열을 선언하거나 int a[3];

타입에 맞는 포인터를 하나 선언한 뒤에 int * a; 생성시에 동적 할당을 받는다. a = new int[3];

 

그렇다면, 지금까지 사용했을 때는 왜 생성자를 명시하지 않았는데도 오류 없이 객체를 생성할 수 있었을까?

만약 생성자를 정의하지 않았다면 기본 생성자(default constructor)가 컴파일러에 의해 자동적으로 생성되기 때문이다!

기본 생성자는 아무것도 하지 않는 빈 몸통을 가진 생성자다. 즉 co3D(){} 와 같이 생성되고 끝이다.

만약 다른 생성자가 하나라도 선언되어 있다면 기본 생성자는 만들어지지 않는다.

 

생성자는 객체를 만들 때 호출되므로 어디서나 사용가능하도록 public 으로 선언되어야 한다. 

하지만 드물게 private으로 선언되는 경우도 있는데, 이 때는 멤버 함수에서만 호출할 수 있다.

가령 메이플 케릭을 만들 때 접속해서 주사위 돌리고 나서 생성을 누르면 생성 함수 내부에서만 케릭터를 생성할 수 있도록 하는 등, 생성에 제한을 줘야할 때 사용된다.

 

소멸자는 생성자와 반대의 개념이다.

객체가 소멸할 때 단 한 번만 호출되며, 생성자와 동일하게 반환타입을 적지 말아야 하며 반환하지도 않고, 매개변수가 필요하지도 않다.

소멸자는 생성자 앞에 ~를 붙여서 만들며, 프로그램 종료시 자동으로 호출되고, 기본 소멸자가 존재한다. 

다만 객체가 동적으로 할당 받은 메모리가 있다면 반드시 delete를 해주어야하므로 소멸자에 보통 명시하여 메모리 누수가 발생하지 않도록 주의한다.

 

 

복사 생성자(copy constructor)

같은 클래스 타입의 변수를 초기화할 때 넣으면 어떻게 될까?

c b = new c();

c a = b;

=연산은 컴파일러에 의해 묵시적으로 생성자로 변환된다. 즉 c a = b; 가 c a(b); 와 동일하게 취급된다. 

이런 방식의 경우 컴파일러는 우리가 명시적으로 생성자를 c(c &b){} 형식으로 만들어주지 않았음에도 불구하고 모든 변수의 값을 복사해준다. 이는 기본 복사 생성자(defulat copy constructor)가 컴파일러에 의해 자동으로 생성되기 때문이다. 

기본 복사 생성자는 멤버 이니셜라이저를 통해 매개 변수의 값들을 멤버 변수로 초기화해준다.

만약 =연산을 통해 생성자가 호출되는 것을 막고 싶다면, a(c &b) 생성자 앞에 explicit 키워드를 삽입하여 막을 수 있다.

 

하지만 기본 복사 생성자에는 문제가 있다.

매개 변수의 값을 그대로 복사해서 대입해주기 때문에 만약 주소값을 넣어줄 경우에 에러가 발생할 수 있다.

그 이유는 할당 해제에 있는데, 둘 중 하나의 객체를 소멸시킬 때에 포인터가 가리키는 메모리 영역을 할당 해제시켰다고 가정해보자. 그럼 소멸하지 않은 객체는 할당이 해제된 곳을 가리키기 때문에 접근하면 에러가 발생한다. 

이러한 복사를 얕은 복사(shallow copy)라고 부른다.

 

만약 복사하되 객체마다 메모리를 할당하여 서로 다른 공간을 가리키게 하고 싶다면 깊은 복사(deep copy)를 해주면 된다.

이는 단순하게 새로운 메모리를 할당받아 기존 객체에 있던 값들을 그대로 복사해서 넣어주면, 같은 값을 가지는 다른 배열이 완성되게 된다! 

 

얕은 복사와 깊은 복사는 너무나도 유명하고 잘나와 있으므로 자세한 설명은 생략한다 ㄱ-

 

하지만 중요한 개념이 하나 남아있다.

 

복사 생성자가 언제 호출되는지가 바로 그것이다.

1. 같은 타입의 객체로 다른 객체를 초기화 할 때(앞서 본 상황)

2. 객체를 call by value 로 인자로 전달할 경우

3. 객체를 참조형으로 반환하지 않는 경우

 

1번은 방금 살펴봤으니까 그렇다쳐도, 2번과 3번은 왜 호출되는가?

 

2번은 C에서 많이 본 상황이다. 

call by value로 호출된 매개변수를 아무리 변경해도 인자로 전달된 변수들은 변하지 않는다. 

함수가 호출될 때 매개 변수들이 메모리 공간에 선언되고 인자들로 초기화 된다. 

그 후 함수가 종료되면 매개 변수들은 메모리 공간에서 지워지기 때문에 변하지 않았다.

여기서 전달될 경우를 자세히 보면, 객체가 call by value로 호출된다면 매개 변수에 복사되어 들어갈 것이란걸 알 수 있다.

그렇다면 자연스럽게 복사 생성자가 호출되는 것을 알 수 있다.

 

3번은 어떤가

객체를 반환하는데 왜 복사 생성자가 호출될까?

우선 함수 안에서 사용된 객체와 반환할 때 사용되는 객체가 서로 다르기 때문이다.

새로운 메모리 공간에 반환값이 저장되고 나서 함수 안에 있던 모든 지역 변수들의 할당이 해제가 되어버리기 때문에, 반환값을 새롭게 복사하여 저장할 필요가 있다. 그렇기 때문에 복사 생성자가 불리는 것이다.

 

이는 C에서도 수도 없이 반복되던 일이다. 

이 새로운 공간에 저장되는 값은 임시적으로 사용되고 코드 문장의 끝에서 사라져버리기 때문에 '임시 객체'라고 불린다.

임시 객체는 우리가 일부러 만들 수도 있으며, 사실 안만들어도 value를 반환할 경우 자동으로 생성된다. 

그렇기 때문에 반환받은 것을 즉시 사용하여 그 임시객체의 멤버 함수들을 사용할 수도 있는 것이다.

 

하지만 참조형으로 반환할 경우 왜 복사 생성자가 호출되지 않을까?

이는 객체의 참조값만을 반환하기 때문에 메모리 공간에는 참조값만을 복사하고 객체를 복사하지 않기 때문이다. 

이렇게 전달된 참조값은 쓰이지 않을 경우 임시 객체이기 때문에 다음 줄에 가서는 사라지지만,

만약 참조자에 의해 참조된다면 임시 객체는 사라지지 않고 그대로 쓸 수 있다.

 

#include <iostream>
#include <cstring>
using namespace std;

class c {
	int x, y;
public:
	c(c& a) {
		x = a.x;
		y = a.y;
		cout << "copy constructor\n";
	}
	c() {
		x = 10;
		y = 10;
	}
	c& func(c a) {
		return a;
	}
};

int main() {
	c a;
	c b;
	c& ref = b.func(a);
}

 

b.func(a)가 call by value이기 때문에 복사 생성자가 단 한 번 호출되는 것을 볼 수 있다.

 

다음과 같은 코드였다면 2번 호출되는 것을 볼 수 있다.

 

#include <iostream>
#include <cstring>
using namespace std;

class c {
	int x, y;
public:
	c(c& a) {
		x = a.x;
		y = a.y;
		cout << "copy constructor\n";
	}
	c() {
		x = 10;
		y = 10;
	}
	c func(c a) {
		return a;
	}
};

int main() {
	c a;
	c b;
	b.func(a);
}

이는 call by value에서 한 번, 반환할 때 메모리에 임시 객체로 복사하기 때문에 한 번 때문임을 이제는 이해할 수 있다.

이런 임시 객체는 자주 발생할 시에 함수에 대한 오버헤드이기 때문에 자주 호출할수록 전체적인 성능 저하로 이어지기 때문에 잘 알아둬야할 필요가 있다.

물론 자바에서는 기본적으로 참조값만을 사용하므로 그럴 일이 없어서 편하다.. (할수록 자바가 짱인가)

 

 

 

 

 

 

this 상수 포인터

객체 자기 자신의 주소값을 가리키는 상수 포인터다. 

객체 a에서 사용된 this 는 a값의 주소를 가리키고, 객체 b에서 사용된 this는 b값의 주소를 가리키므로 객체 의존적이다. 

주로 매개변수의 이름과 멤버 변수가 같을 때, 멤버 변수에 접근하기 위한 포인터로 사용되며, 참조값을 반환할 때 *this를 반환하여 자기 자신의 참조값을 반환할 때 사용되기도 한다. 

이것의 의미는 후에 연산자 오버로딩 할 때 다시 보기로 한다.

 

friend 선언

friend선언은 정말 특이한 경우이다.

같은 클래스안에 선언이 되어있지 않아도, 클래스 안에서 friend로 선언해줄 경우에는 friend로 선언된 아이가 자신의 private 멤버 변수까지 접근할 수 있도록 허용해준다. class 에 붙이던 함수에 붙이던 상관은 없다.

이는 상당히 편할 수 있지만, 정보은닉의 개념을 뛰어넘어버리기 때문에 자주 사용하진 않는다.

다만 표준 입출력을 연산자 오버로딩할 경우에 유용하게 사용되므로 잊지는 말자.

 

static 선언

C에서 static은 2가지로 쓰였다.

전역변수에 사용될 경우 해당 파일 안에서만 사용하고 외부에서 참조하지 못하도록 하겠다는 의미이고,

함수 내에서 선언될 경우, 프로그램 실행시 단 한 번만 초기화되고, 지역 변수처럼 소멸되지 않게 해준다.

 

클래스에서도 마찬가지이다.

static변수는 공유용이다.

클래스 내에서 선언될 경우 단 한 번만 초기화되므로, 해당 클래스 타입의 모든 객체는 그 변수를 공유한다고 볼 수 있다. 

객체지향에서는 전역변수를 쓰지 않는 것이 안정성에 있어서 좋으므로 static선언을 이용해 공유할 수 있는 변수를 선언하자.

또한 단 한 번만 초기화해야하기 때문에 생성자에 초기화를 넣지 않는다. 

대신 클래스 외부에 classname::staticintname = 0; 처럼 초기화해줘야 한다. 

이것을 통해 static 멤버 변수는 객체가 없어도 프로그램 실행시에 초기화된다는 것도 알 수 있다.

 

static함수도 마찬가지이다.

모든 객체가 공유한다. 또한 객체가 없어도 호출이 가능한 특징이 있는데 이는 static 함수는 객체의 멤버가 아니기 때문이다. 

객체의 멤버가 아니라는 것은 static 함수를 통해 멤버 변수의 초기화가 불가능한 일이라는 것을 의미한다.

때문에 static 함수는 static 변수와 static 함수만을 호출할 수 있고, 객체 없이도 호출 가능하므로 전역으로 사용될 수 있다.

이런 특성 때문에 const static int a = 123123; 으로 바로 초기화가 가능한 차이점이 있다.(구형 컴파일러에서)

 

mutable 키워드

mutable int a처럼 사용되면은 const로 선언된 상태에서도 값을 변경할 수 있게 만들어주는 키워드이다.

const로 선언된 함수는 멤버 변수를 변경할 수 없게 만들어주는데, 만약 멤버 변수가 mutable이라면 그 변수는 값을 변경할 수 있다. 

이런 예외성은 역시 통일성을 깨뜨리고 혼란을 잘 야기하기 때문에 되도록 쓰지 않는 것이 좋다.