단순히 C++의 예제를 보여주는 것 말고도 스캐너와 파서 발생기도 다룬다. 이런 도구들이 C++ 프로그램에 어떻게 사용되는지 보여준다. 이런 전통적인 예제들은 여러분이 문법이라든가 파스-트리 그리고 파스-트리 장식 등등과 같이 그 밑에 깔린 개념에 어느 정도 익숙하다고 가정한다. 프로그램의 입력이 일정한 복잡도를 넘어서면 스캐너와 파서 생성기로 코드를 만들어 입력을 처리하는 방법이 더 낫다. 이런 도구를 C++ 환경에서 사용하는 방법을 이 장의 예제 중 하나에 보여준다.
std::streambuf
클래스를 발판으로 삼아 자연스럽게 파일 기술자와 같은 장치와의 인터페이스를 담당할 클래스를 생성해 보자.
아래에서 클래스를 하나 생성해 파일 기술자에 주어진 장치에 써 보겠다. 이 장치는 파일 또는 파이프나 소켓이 될 수도 있다. 24.1.2항은 그런 장치로부터 읽는 법을 다룬다. 24.2.3항은 이전 6.6.2항에서 다룬 바 있는 방향전환을 더 깊게 연구한다.
streambuf
를 바탕 클래스로 사용하면 출력 연산용 클래스를 상대적으로 쉽게 설계할 수 있다. 재정의할 유일한 멤버함수는 int streambuf::overflow(int c)
가상 멤버이다. 이 멤버는 문자를 장치에 쓰는 책임을 진다. fd
가 출력 파일 기술자이고 그 출력을 버퍼 처리하면 안될 경우에 overflow()
멤버는 그냥 다음과 같이 구현하면 된다.
class UnbufferedFD: public std::streambuf { public: virtual int overflow(int c); ... }; int UnbufferedFD::overflow(int c) { if (c != EOF) { if (write(d_fd, &c, 1) != 1) return EOF; } return c; }
overflow
멤버함수가 받는 인자는 성공적으로 파일 기술자에 씌여지거나 아니면 EOF
가 반환된다.
이 간단한 함수는 출력 버퍼를 사용하지 않는다. 다양한 이유로 출력 버퍼를 사용하는 것은 좋은 생각이다 (다음 절도 참고).
출력 버퍼를 사용하면 overflow
멤버함수는 약간 더 복잡하다. 버퍼가 충만할 때만 호출되기 때문이다. 버퍼가 꽉 차면 먼저 버퍼를 비워야 한다. 버퍼를 비우는 것은 streambuf::sync
가상 함수의 책임이다. sync
함수는 가상이므로 streambuf
로부터 파생된 클래스는 sync
를 재정의해 streambuf
가 알지 못하는 버퍼를 비울 수 있다.
sync
를 재정의하고 그것을 overflow
안에서 사용하는 것만으로는 부족하다. 버퍼를 정의한 클래스의 실체가 여생을 마칠 때 그 버퍼는 부분적으로만 비워질 수도 있다. 그렇다면 버퍼도 역시 비워야 한다. 이것은 그냥 클래스의 소멸자에서 sync
를 호출하기만 하면 해결된다.
이제 출력 버퍼의 중요성을 연구해 보았으므로 우리의 파생 클래스를 설계할 준비가 거의 된 셈이다. 그렇지만 여러 특징을 더 추가해야 한다.
OFdnStreambuf
클래스는 다음과 같은 특징이 있다.
streambuf
말고도 <unistd.h>
헤더를 컴파일러에게 읽혀야 멤버 함수를 컴파일할 수 있다.
std::streambuf
로부터 클래스가 파생된다.
class OFdnStreambuf: public std::streambuf { size_t d_bufsize; int d_fd; char *d_buffer; public: OFdnStreambuf(); OFdnStreambuf(int fd, size_t bufsize = 1); virtual ~OFdnStreambuf(); void open(int fd, size_t bufsize = 1); private: virtual int sync(); virtual int overflow(int c); };
open
멤버에 건넨다 (아래 참고). 다음은 생성자이다.
inline OFdnStreambuf::OFdnStreambuf() : d_bufsize(0), d_buffer(0) {} inline OFdnStreambuf::OFdnStreambuf(int fd, size_t bufsize) { open(fd, bufsize); }
sync
멤버를 호출하여 출력 버퍼에 저장된 문자를 모두 장치로 비운다. 버퍼를 사용하지 않는 구현이라면 기본 구현으로 소멸자를 주면 된다.
inline OFdnStreambuf::~OFdnStreambuf() { if (d_buffer) { sync(); delete[] d_buffer; } }
이 구현은 장치를 닫지 않는다. 장치가 선택적으로 닫히도록 (또는 그냥 열린 채로 두도록) 이 클래스를 바꾸는 것은 독자 여러분에게 연습 문제로 남겨 둔다. 이 접근법은 Bobcat 라이브러리에 채택되어 있다 (24.1.2.2목).
open
멤버는 버퍼를 초기화한다. streambuf::setp
를 사용하여 버퍼의 시작과 끝 지점을 정의한다. streambuf
바탕 클래스가 streambuf::pbase
와 streambuf::pptr
그리고 streambuf::epptr
를 초기화한다.
inline void OFdnStreambuf::open(int fd, size_t bufsize) { d_fd = fd; d_bufsize = bufsize == 0 ? 1 : bufsize; d_buffer = new char[d_bufsize]; setp(d_buffer, d_buffer + d_bufsize); }
sync
멤버는 아직 비우지 못한 버퍼의 내용을 장치로 비운다. 비우고 나면 버퍼는 setp
를 사용하여 재초기화된다. 성공적으로 비우면 sync
는 버퍼 0을 돌려준다.
inline int OFdnStreambuf::sync() { if (pptr() > pbase()) { write(d_fd, d_buffer, pptr() - pbase()); setp(d_buffer, d_buffer + d_bufsize); } return 0; }
streambuf::overflow
멤버도 재정의된다. 이 멤버는 버퍼가 찰 때 streambuf
바탕 클래스로부터 호출되기 때문에 제일 먼저 sync
를 호출해 버퍼를 장치로 비워야 한다. 다음 c
문자를 (이제는 비어 있는) 버퍼에 써야 한다. 문자 c
는 pptr
과 streambuf::pbump
를 사용하여 써야 한다. 문자를 버퍼에 넣는 것은 `손수 만들지 말고' streambuf
멤버 함수를 사용하여 구현한다. streambuf
의 내부 상태가 훼손될 수 있기 때문이다. 다음은 overflow
의 구현이다.
inline int OFdnStreambuf::overflow(int c) { sync(); if (c != EOF) { *pptr() = c; pbump(1); } return c; }
OFfdStreambuf
클래스를 사용하여 자신의 표준 입력을 파일 기술자 STDOUT_FILENO
에 복사한다. 이 파일 기술자는 표준 출력에 사용되는 심볼이다.
#include <string> #include <iostream> #include <istream> #include "fdout.h" using namespace std; int main(int argc, char **argv) { OFdnStreambuf fds(STDOUT_FILENO, 500); ostream os(&fds); switch (argc) { case 1: for (string s; getline(cin, s); ) os << s << '\n'; os << "COPIED cin LINE BY LINE\n"; break; case 2: cin >> os.rdbuf(); // 다음과 같이 사용해도 됨: cin >> &fds; os << "COPIED cin BY EXTRACTING TO os.rdbuf()\n"; break; case 3: os << cin.rdbuf(); os << "COPIED cin BY INSERTING cin.rdbuf() into os\n"; break; } }
std::streambuf
으로부터 파생시킬 때 적어도 문자 하나 만큼의 입력 버퍼는 제공해야 한다. 한-문자 입력 버퍼로 istream::putback
나 istream::ungetc
멤버 함수를 사용할 수 있다. 엄밀히 이야기해 streambuf
로부터 파생된 클래스에 버퍼를 구현하는 것이 필수는 아니다. 그러나 버퍼를 사용하는 편이 좋다. 구현이 아주 간단하고 눈에 보이는 그대로 이해되며 클래스의 응용성이 크게 향상되기 때문이다. 그러므로 streambuf
으로부터 파생시킨 모든 클래스에 적어도 한 문자 정도의 버퍼는 정의되어 있다.
streambuf
로부터 (IFdStreambuf
) 클래스를 상속받을 때 적어도 streambuf::underflow
멤버는 반드시 재정의해야 한다. 이 멤버가 결국 모든 입력 요청에 응답하기 때문이다. streambuf::setg
멤버를 사용하여 streambuf
바탕 클래스에게 입력 버퍼의 위치와 크기를 알린다. 그래서 그에 맞게 입력 버퍼 포인터를 설정할 수 있다. 이렇게 하면 streambuf::eback
와 streambuf::gptr
그리고 streambuf::egptr
가 올바른 값을 돌려준다고 확신할 수 있다.
IFdStreambuf
클래스는 다음과 같이 설계한다.
streambuf
외에도 먼저 <unistd.h>
헤더를 컴파일러에게 읽혀야 멤버 함수를 컴파일할 수 있다.
std::streambuf
으로부터 파생된다.
class IFdStreambuf: public std::streambuf { protected: int d_fd; char d_buffer[1]; public: IFdStreambuf(int fd); private: int underflow(); };
gptr
의 반환 값을 egptr
의 반환 값과 같게 초기화한다. 즉, 버퍼가 비면 즉시 underflow
가 호출되어 버퍼를 채운다는 뜻이다.
inline IFdStreambuf::IFdStreambuf(int fd) : d_fd(fd) { setg(d_buffer, d_buffer + 1, d_buffer + 1); }
underflow
를 재정의한다. 버퍼는 파일 기술자로부터 읽어서 다시 채워진다. (어떤 이유로든) 읽기에 실패하면 EOF
가 반환된다. 물론 여기에서 더 섬세하게 구현하면 더 지능적으로 행동할 수 있을 것이다. 버퍼를 다시 채울 수 있으면 setg
가 호출되어 streambuf
의 버퍼 포인터를 올바르게 설정한다.
inline int IFdStreambuf::underflow() { if (read(d_fd, d_buffer, 1) <= 0) return EOF; setg(d_buffer, d_buffer, d_buffer + 1); return *gptr(); }
main
함수는 IFdStreambuf
를 어떻게 사용하는지 보여준다.
int main() { IFdStreambuf fds(STDIN_FILENO); istream is(&fds); cout << is.rdbuf(); }
IFdStreambuf
클래스와 같다. 좀 흥미롭게 하기 위해 일련의 문자를 읽는 것을 최적화해보자. 여기에서 개발된 IFdNStreambuf
클래스에 있는 streambuf::xsgetn
멤버로 재정의한다. 또한 기본 생성자를 제공한다. open
멤버와 조합해 사용하면 파일 기술자를 사용할 수 있기도 전에 istream
객체를 생성할 수 있다. 그 경우에 기술자를 사용할 수 있으면 open
멤버를 사용하여 객체의 버퍼를 초기화할 수 있다. 나중에 24.2절에서 그런 상황을 만나 보겠다.
지면을 절약하기 위해 다양한 호출의 성공 여부는 점검하지 않았다. 물론`실 세계'의 구현이라면 이런 점검을 빼먹으면 안 된다. IFdNStreambuf
클래스는 다음과 같은 특징이 있다.
streambuf
말고도 <unistd.h>
헤더를 컴파일러에게 읽혀야 멤버 함수를 컴파일할 수 있다.
std::streambuf
로부터 파생된다.
IFdStreambuf
클래스처럼 데이터 멤버는 보호 구역에 배치된다 (24.1.2.1목). 버퍼의 크기를 바꿀 수 있으므로 이 크기는 전용 d_bufsize
데이터 멤버에 보관한다.
class IFdNStreambuf: public std::streambuf { protected: int d_fd; size_t d_bufsize; char* d_buffer; public: IFdNStreambuf(); IFdNStreambuf(int fd, size_t bufsize = 1); virtual ~IFdNStreambuf(); void open(int fd, size_t bufsize = 1); private: virtual int underflow(); virtual std::streamsize xsgetn(char *dest, std::streamsize n); };
open
멤버에 건넨다. 그러면 open
멤버는 그 객체를 실제로 사용할 수 있도록 초기화한다.
inline IFdNStreambuf::IFdNStreambuf() : d_bufsize(0), d_buffer(0) {} inline IFdNStreambuf::IFdNStreambuf(int fd, size_t bufsize) { open(fd, bufsize); }
open
멤버에 의하여 초기화되었으면 그의 소멸자는 객체의 버퍼를 삭제하고 파일 기술자를 사용하여 장치를 닫는다.
IFdNStreambuf::~IFdNStreambuf() { if (d_bufsize) { close(d_fd); delete[] d_buffer; } }
위의 구현에서 장치를 닫았지만 이것이 언제나 바람직한 것은 아니다. open
파일 기술자가 아직 열려 있다는 것은 새로 생성된 IFdNStreambuf
객체를 사용할 때마다 그 기술자를 반복적으로 사용하려는 의도일 수 있다. 장치를 선택적으로 닫도록 이 클래스를 바꾸는 것은 독자 여러분에게 연습 문제로 남겨 둔다. 이 접근법은 Bobcat 라이브러리를 따랐다.
open
멤버는 그 객체의 버퍼를 할당한다. 호출 프로그램은 이미 장치를 열었다고 간주한다. 버퍼가 할당되었으면 바탕 클래스의 setg
멤버를 사용하여 확실하게 streambuf::eback
멤버와 streambuf::gptr
멤버 그리고 streambuf::egptr
멤버가 올바른 값을 돌려주도록 만든다.
void IFdNStreambuf::open(int fd, size_t bufsize) { d_fd = fd; d_bufsize = bufsize; d_buffer = new char[d_bufsize]; setg(d_buffer, d_buffer + d_bufsize, d_buffer + d_bufsize); }
underflow
멤버는 IFdStreambuf
의 멤버와 거의 동일하게 구현되었다 (24.1.2.1목). 현재 클래스의 버퍼가 크기가 더 크다는 차이가 있을 뿐이다. 그러므로 한 번에 (d_bufsize
까지) 더 많은 문자를 장치로부터 읽어 들일 수 있다.
int IFdNStreambuf::underflow() { if (gptr() < egptr()) return *gptr(); int nread = read(d_fd, d_buffer, d_bufsize); if (nread <= 0) return EOF; setg(d_buffer, d_buffer, d_buffer + nread); return *gptr(); }
xsgetn
를 재정의한다. 회돌이에서 n
변수는 0이 될 때까지 줄어든다. 0이 되면 함수는 끝난다. 대안으로, underflow
가 문자를 더 확득하는 데 실패하면 멤버가 돌아온다. 이 멤버는 일련의 문자를 읽는 것을 최적화한다. streambuf::sbumpc
를 n
번 호출하는 대신에 한 무더기의 avail
문자들이 목적지로 복사된다. streambuf::gbump
함수 호출 한 번으로 avail
문자들을 버퍼로부터 소비한다.
std::streamsize IFdNStreambuf::xsgetn(char *dest, std::streamsize n) { int nread = 0; while (n) { if (!in_avail()) { if (underflow() == EOF) break; } int avail = in_avail(); if (avail > n) avail = n; memcpy(dest + nread, gptr(), avail); gbump(avail); nread += avail; n -= avail; } return nread; }
xsgetn
멤버 함수는 streambuf
객체의 streambuf::sgetn
멤버에 의하여 호출된다. 다음은 이 멤버 함수를 IFdNStreambuf
객체와 함께 사용하는 법을 보여주는 예이다.
#include <unistd.h> #include <iostream> #include <istream> #include "ifdnbuf.h" using namespace std; int main() { // 내부적으로: 30개의 문자 버퍼임 IFdNStreambuf fds(STDIN_FILENO, 30); char buf[80]; // main()는 문자 80개를 // 한 블록으로 읽음 while (true) { size_t n = fds.sgetn(buf, 80); if (n == 0) break; cout.write(buf, n); } }
std::streambuf
로부터 상속된 클래스는 streambuf::seekoff
멤버와 streambuf::seekpos
멤버를 재정의해야 한다. 이 목에서 개발한 IFdSeek
클래스를 사용하면 찾기 연산을 지원하는 장치로부터 정보를 읽을 수 있다. IFdSeek
클래스는 IFdStreambuf
로부터 파생되었다. 그래서 딱 한 문자짜리 버퍼를 사용한다. IFdSeek
클래스에 추가된 위치 찾기 연산은 요구받을 때마다 입력 버퍼를 재설정해야 한다. 이 클래스는 또 IFdNStreambuf
클래스로부터 파생시킬 수도 있다. 그 경우에 남아있는 입력 버퍼 다음을 두 번째 세 번째 매개변수가 가리키도록 입력 버퍼를 재설정해야 한다. IFdSeek
의 특징을 한 번 살펴 보자:
IFdSeek
는 IFdStreambuf
으로부터 파생된다. 후자의 클래스처럼 IFdSeek
의 멤버 함수는 unistd.h
에 선언된 편의기능을 사용한다. 그래서 헤더 파일 <unistd.h>
를 컴파일러에게 읽혀야 클래스의 멤버 함수를 컴파일할 수 있다. streambuf
와 std::ios
으로부터 유형과 상수를 지정하는 데 타자하는 양을 줄이기 위하여 typedef
를 여럿 정의했다. 이 typedef
는 <ios>
헤더 파일에 정의된 유형을 참조한다. 그러므로 먼저 컴파일러에게 읽혀 주어야 IFdSeek
의 클래스 인터페이스를 컴파일할 수 있다.
class IFdSeek: public IFdStreambuf { typedef std::streambuf::pos_type pos_type; typedef std::streambuf::off_type off_type; typedef std::ios::seekdir seekdir; typedef std::ios::openmode openmode; public: IFdSeek(int fd); private: pos_type seekoff(off_type offset, seekdir dir, openmode); pos_type seekpos(pos_type offset, openmode mode); };
inline IFdSeek::IFdSeek(int fd) : IFdStreambuf(fd) {}
seek_off
멤버는 실제 찾기 연산을 수행하는 책임을 진다. lseek
멤버를 호출하여 파일 기술자는 알려진 장치에서 새 위치를 찾는다. 찾기에 성공하면 setg
멤버가 호출되어 미리 비어 있는 버퍼를 정의한다. 그래서 바탕 클래스의 underflow
멤버는 다음 입력 요청에 따라 버퍼를 다시 채운다.
IFdSeek::pos_type IFdSeek::seekoff(off_type off, seekdir dir, openmode) { pos_type pos = lseek ( d_fd, off, (dir == std::ios::beg) ? SEEK_SET : (dir == std::ios::cur) ? SEEK_CUR : SEEK_END ); if (pos < 0) return -1; setg(d_buffer, d_buffer + 1, d_buffer + 1); return pos; }
seekpos
동료 함수도 역시 재정의한다. 실제로는 seekoff
멤버를 호출하는 것으로 정의되어 있다.
inline IFdSeek::pos_type IFdSeek::seekpos(pos_type off, openmode mode) { return seekoff(off, std::ios::beg, mode); }
IFdSeek
클래스를 사용하는 프로그램의 예이다. 입력을 방향전환하여 이 프로그램에 소스 파일을 넘기면 찾기가 지원된다 (그리고 첫 줄은 제외하고 다른 줄들은 매 줄마다 두번 보여준다):
#include "fdinseek.h" #include <string> #include <iostream> #include <istream> #include <iomanip> using namespace std; int main() { IFdSeek fds(0); istream is(&fds); string s; while (true) { if (!getline(is, s)) break; streampos pos = is.tellg(); cout << setw(5) << pos << ": `" << s << "'\n"; if (!getline(is, s)) break; streampos pos2 = is.tellg(); cout << setw(5) << pos2 << ": `" << s << "'\n"; if (!is.seekg(pos)) { cout << "Seek failed\n"; break; } } }
streambuf
클래스와 그의 파생 클래스들은 적어도 마지막 읽은 문자를 버리는 기능을 지원해야 한다. 일련의 unget
호출을 지원해야 할 때 특별히 주의를 기울여야 한다. 이 절에서는 istream::unget
호출 또는 istream::putback
호출 횟수를 지정할 수 있는 클래스의 생성을 연구한다.
여러 (`n
'회의) unget
호출 지원은 입력 버퍼의 앞 부분을 예약함으로써 구현된다. 예약된 부분은 마지막으로 읽은 n
개의 문자를 담기 위하여 점차로 채워진다. 클래스는 다음과 같이 구현된다.
std::streambuf
로부터 클래스를 상속받는다. 크기를 바꿀 수 있는 복구-버퍼를 유지 관리하기 위하여 여러 데이터 멤버를 정의한다.
class FdUnget: public std::streambuf { int d_fd; size_t d_bufsize; size_t d_reserved; char *d_buffer; char *d_base; public: FdUnget(int fd, size_t bufsz, size_t unget); virtual ~FdUnget(); private: int underflow(); };
d_reserved
개의 바이트로 정의된다.
입력 버퍼는 언제나 최소한 d_reserved
보다 1 바이트가 더 크다. 그래서 바이트를 얼마든지 읽을 수있다. d_reserved
개의 바이트를 읽으면 최대 d_reserved
개의 바이트를 버릴 수 있다.
다음, 읽기가 시작될 지점을 구성한다. d_base
라고 부르는데 d_buffer
위치를 d_reserved
바이트 만큼 지난 위치를 가리킨다. 이곳에서 언제나 버퍼 재충전이 시작된다.
이제 버퍼를 구성했으므로 setg
를 사용하여 streambuf
의 버퍼 포인터를 정의할 준비가 되었다. 아직 문자를 하나도 읽지 않았기 때문에 모든 포인터는 d_base
를 가리키도록 설정된다. unget
이 이 시점에 호출되더라도 문자가 없으므로 unget
은 (올바르게) 실패한다.
마침내 할당된 바이트의 갯수에서 예약된 구역의 크기를 뺀 값으로 재충전 버퍼 크기가 결정된다.
FdUnget::FdUnget(int fd, size_t bufsz, size_t unget) : d_fd(fd), d_reserved(unget) { size_t allocate = bufsz > d_reserved ? bufsz : d_reserved + 1; d_buffer = new char[allocate]; d_base = d_buffer + d_reserved; setg(d_base, d_base, d_base); d_bufsize = allocate - d_reserved; }
inline FdUnget::~FdUnget() { delete[] d_buffer; }
underflow
는 다음과 같이 재정의된다.
먼저 underflow
는 잠재적으로 버릴 수 있는 문자의 갯수를 결정한다. 그 갯수만큼 문자를 버리면 입력 버퍼는 고갈된다. 그래서 이 값은 (최초 상태인) 0부터 (예약 구역이 완전히 채워질 때, 그리고 현재 버퍼에 남아 있는 나머지 문자도 모두 읽었을 때인) 입력 버퍼 크기 사이에 있다.
다음으로 예약 구역 안으로 이동할 바이트의 갯수가 계산된다. 이 갯수는 최대 d_reserved
개이다. 그러나 그 갯수는 이 값이 버릴 수 있는 실제 문자 갯수보다 더 작으면 실제 문자 갯수와 같게 설정된다.
예약 구역으로 이동시킬 문자의 갯수를 알고 있으므로 이 갯수 만큼의 문자를 입력 버퍼의 끝으로부터 d_base
의 바로 앞에 있는 예약 구역으로 이동시킨다.
그러면 버퍼가 채워진다. 이 모든 것은 표준적이지만 읽기는 d_buffer
가 아니라 d_base
로부터 시작된다는 것을 주의하라.
마지막으로 streambuf
의 읽기 버퍼 포인터들을 설정한다. Eback
은 d_base
바로 앞 위치로 이동(move
)되도록 설정되는데 그리하여 복구-구역을 확실하게 보장하려는 것이다. gptr
은 d_base
로 설정된다. 왜냐하면 그 위치가 바로 재충전 후에 처음으로 읽은 문자가 있는 위치이기 때문이다. 그리고 egptr
은 버퍼 안으로 읽어 들인 마지막 문자 바로 다음 위치로 설정된다.
underflow
의 구현이다.
int FdUnget::underflow() { size_t ungetsize = gptr() - eback(); size_t move = std::min(ungetsize, d_reserved); memcpy(d_base - move, egptr() - move, move); int nread = read(d_fd, d_base, d_bufsize); if (nread <= 0) // 더 이상 읽지 않고 -> EOF를 돌려준다. return EOF; setg(d_base - move, d_base, d_base + nread); return *gptr(); }
FdUnget을 사용하는 예제
다음 예제 프로그램은 FdUnget
클래스의 사용법을 보여준다. 최대 10개의 문자를 표준 입력으로부터 읽는다. EOF
에서 멈춘다. 2 문자 짜리의 보장된 언겟-버퍼는 3 문자를 담은 버퍼에 정의된다. 한 문자를 읽기 바로 전에 프로그램은 최대 6개의 문자를 버리려고 시도한다. 물론 이것은 불가능하다. 그러나 멋지게도 프로그램은 읽은 문자의 실제 갯수를 고려하여 얼마든지 문자를 버린다.
#include "fdunget.h" #include <string> #include <iostream> #include <istream> using namespace std; int main() { FdUnget fds(0, 3, 2); istream is(&fds); char c; for (int idx = 0; idx < 10; ++idx) { cout << "after reading " << idx << " characters:\n"; for (int ug = 0; ug <= 6; ++ug) { if (!is.unget()) { cout << "\tunget failed at attempt " << (ug + 1) << "\n" << "\trereading: '"; is.clear(); while (ug--) { is.get(c); cout << c; } cout << "'\n"; break; } } if (!is.get(c)) { cout << " reached\n"; break; } cout << "Next character: " << c << '\n'; } } /* 'echo abcde | program' 위와 같이 실행한 후의 출력: after reading 0 characters: unget failed at attempt 1 rereading: '' Next character: a after reading 1 characters: unget failed at attempt 2 rereading: 'a' Next character: b after reading 2 characters: unget failed at attempt 3 rereading: 'ab' Next character: c after reading 3 characters: unget failed at attempt 4 rereading: 'abc' Next character: d after reading 4 characters: unget failed at attempt 4 rereading: 'bcd' Next character: e after reading 5 characters: unget failed at attempt 4 rereading: 'cde' Next character: after reading 6 characters: unget failed at attempt 4 rereading: 'de ' reached */
istream
객체로부터 정보를 추출할 때 표준 추출 연산자인 operator>>
가 이 작업에 딱 맞춤이다. 추출된 필드는 대부분 서로 공백으로 명확하게 구분되기 때문이다. 그러나 이것이 모든 상황에 들어 맞는 것은 아니다. 예를 들어 웹-폼을 처리 스크립트나 프로그램에 보내면 프로그램은 폼 필드의 값을 url-인코드된 문자로 받는다. 기호와 숫자는 그대로 전송된다. 공백은 +
문자로 전송되고 다른 모든 문자는 %
로 시작한 다음에 그 문자의 ascii-값이 두 자리 십육진수로 표현되어 전송된다.
url-인코드된 정보를 해독할 때 간단한 십육진 추출은 작동하지 않는다. 그러면 단지 문자 두 개가 아니라 되도록이면 많은 십육진 문자를 추출하기 때문이다. a-f`
와 0-9
사이의 문자는 적법한 십육진 문자이기 때문에 My name is `Ed'
와 같은 텍스트는 다음과 같이 url-인코드된다.
My+name+is+%60Ed%27결과적으로
60
과 27
이 아니라 십육진 값 60ed
그리고 27
이 추출된다. 이름 Ed
는 시야로부터 사라진다. 이것이 우리가 원하는 것은 아니다.
이 경우에 %
를 보았으므로 istringstream
객체에 배정된 2 문자를 추출할 수 있다. 그리고 istringstream
객체로부터 십육진 값을 추출한다. 약간 귀찮지만 할 만하다. 다른 접근법도 역시 가능하다.
Fistream
클래스는 크기가 고정된 필드에 대하여 istream
클래스를 정의한다. 이 클래스는 (비형식 read
호출은 물론이고) 고정된 크기의 필드 추출 그리고 공백으로 구분된 추출을 모두 지원한다. 이 클래스는 기존의 istream
을 감싼 포장 클래스로 초기화할 수 있다. 아니면 기존의 파일이름을 사용하여 초기화할 수 있다. 이 클래스는 istream
으로부터 파생된다. 일반적으로 istream
이 지원하는 모든 추출 연산을 허용한다. Fistream
은 다음의 데이터 멤버를 정의한다.
d_filebuf
: (기존의) 이름붙은 파일로부터 Fistream
이 정보를 읽을 때 사용하는 파일 버퍼이다. 읽을 때만 파일 버퍼가 필요하고 동적으로 할당되어야 하므로 unique_ptr<filebuf>
객체로 정의된다.
d_streambuf
: Fistream
의 streambuf
를 가리키는 포인터. Fistream
이 이름으로 파일을 열면 d_filebuf
를 가리킨다. 기존의 istream
을 사용하여 Fistream
을 생성하면 기존의 istream
의 streambuf
를 가리킨다.
d_iss
: 고정 크기의 필드 추출에 사용되는 istringstream
객체.
d_width
: 추출할 필드의 너비를 나타내는 size_t
. 0이면 고정 크기의 필드 추출이 사용되지 않는다. 그러나 정보는 표준 추출을 사용하여 istream
바탕 클래스 객체로부터 추출된다.
Fistream
의 클래스 인터페이스의 처음 부분이다.
class Fistream: public std::istream { std::unique_ptr<std::filebuf> d_filebuf; std::streambuf *d_streambuf; std::istringstream d_iss; size_t d_width;
언급한 바와 같이 Fistream
객체는 파일이름이나 기존의 istream
객체로부터 생성할 수 있다. 그러므로 클래스 인터페이스는 두 개의 생성자를 선언한다.
Fistream(std::istream &stream); Fistream(char const *name, std::ios::openmode mode = std::ios::in);
기존의 istream
객체를 사용하여 Fistream
객체를 생성할 때 Fistream
의 istream
부분은 단순히 stream
의 streambuf
객체를 사용한다.
Fistream::Fistream(istream &stream) : istream(stream.rdbuf()), d_streambuf(rdbuf()), d_width(0) {}
파일이름으로 fstream
객체를 생성할 때 streambuf
로 사용될 filebuf
객체가 istream
바탕 초기화자에 새로 주어진다. 클래스의 데이터 멤버는 클래스의 바탕 클래스가 생성되기 전에 먼저 초기화되지 않기 때문에 d_filebuf
는 그 다음에야 겨우 초기화될 수 있다. 그 때까지 filebuf
는 그냥 streambuf
를 돌려주는 rdbuf
로만 사용할 수 있을 뿐이다. 그렇지만 실제로는 filebuf
이므로 static_cast
를 사용하면 rdbuf
가 돌려주는 streambuf
포인터를 filebuf *
로 유형을 변환할 수 있다. 그래서 d_filebuf
를 초기화할 수 있다.
Fistream::Fistream(char const *name, ios::openmode mode) : istream(new filebuf()), d_filebuf(static_cast<filebuf *>(rdbuf())), d_streambuf(d_filebuf.get()), d_width(0) { d_filebuf->open(name, mode); }
setField(field const &)
공개 멤버가 하나 추가되었다. 이 멤버는 추출할 다음 필드의 크기를 정의한다. 그의 매개변수는 field
클래스에 대한 참조이다. 이 클래스는 다음 필드의 너비를 정의하는 조작자 클래스이다.
field &
는 Fistream
의 인터페이스에 언급되어 있으므로 field
를 먼저 선언해야 비로서 Fistream
의 인터페이스가 시작된다. field
클래스 자체는 단순하며 Fistream
을 친구로 선언한다. 두 개의 데이터 멤버가 있다. d_width
는 다음 필드의 너비를 지정한다. d_newWidth
는 d_width
의 값이 실제로 사용되어야 한다면 true
로 설정된다. d_newWidth
가 false
이면 Fistream
은 표준 추출 모드로 돌아간다. field
클래스는 생성자가 두 개이다. 기본 생성자는 d_newWidth
를 false
로 설정하고 두 번째 생성자는 추출할 다음 필드의 너비를 자신의 값으로 기대한다. 다음은 field
클래스의 구현이다.
class field { friend class Fistream; size_t d_width; bool d_newWidth; public: field(size_t width); field(); }; inline field::field(size_t width) : d_width(width), d_newWidth(true) {} inline field::field() : d_newWidth(false) {}
field
클래스는 Fistream
클래스를 친구로 선언하기 때문에 setField
멤버는 field
의 멤버들을 직접적으로 들여다 볼 수 있다.
이제 setField
로 돌아갈 시간이다. 이 함수는 field
객체를 참조로 기대한다. 세 가지 다른 방식 중 하나로 초기화된다.
field()
: setField
의 인자가 기본 생성자로 생성된 field
객체일 때 다음 추출은 이전 추출과 같은 필드 너비를 사용할 것이다.
field(0)
: 이 field
객체를 setField
의 인자로 사용할 때 고정-크기의 필드 추출은 멈춘다. 그리고 Fistream
이 다시 표준 istream
객체처럼 행위한다.
field(x)
: field
객체 자체가 0 아닌 size_t 값 x
로 초기화되면 다음 필드 너비는 x
개의 문자 너비가 된다. 그런 필드의 준비는 Fistream
의 유일한 비밀 멤버인 setBuffer
에게 맡긴다.
setField
의 구현이다.
std::istream &Fistream::setField(field const ¶ms) { if (params.d_newWidth) // 새로운 필드 크기가 요구됨 d_width = params.d_width; // 새로운 너비를 설정한다. if (!d_width) // 너비가 없다면? rdbuf(d_streambuf); // 예전 버퍼로 되돌아간다. else setBuffer(); // 추출 버퍼를 정의한다. return *this; }
비밀 setBuffer
멤버는 d_width + 1
개의 문자 버퍼를 정의하고 read
를 사용하여 그 버퍼를 d_width
개의 문자로 채운다. 버퍼는 NTBS(0으로 끝나는 문자열)이다. 이 버퍼를 사용하여 d_iss
멤버를 초기화한다. Fistream
의 rdbuf
멤버로 Fistream
객체 자체를 통하여 d_str
의 데이터를 추출한다.
void Fistream::setBuffer() { char *buffer = new char[d_width + 1]; rdbuf(d_streambuf); // istream의 버퍼에 buffer[read(buffer, d_width).gcount()] = 0; // d_width 개의 문자를 읽는다. // 문자열은 0-바이트로 끝난다. d_iss.str(buffer); delete[] buffer; rdbuf(d_iss.rdbuf()); // 버퍼를 전환한다. }
setField
를 사용하면 Fistream
이 고정 크기로 필드를 추출할지 말지 설정해 줄 수 있지만 아마도 조작자를 사용하는 편이 더 좋을 것이다. field
객체를 조작자로 사용하기 위해 추출 연산자를 중복정의했다. 이 추출 연산자는 istream &
과 field const &
객체를 받는다. 이 추출 연산자를 사용하면 다음과 같은 서술문이 가능하다.
fis >> field(2) >> x >> field(0);(
fis
는 Fistream
객체라고 간주한다). 다음은 중복정의 operator>>
와 더불어 그의 선언이다.
istream &std::operator>>(istream &str, field const ¶ms) { return static_cast<Fistream *>(&str)->setField(params); }
선언:
namespace std { istream &operator>>(istream &str, FBB::field const ¶ms); }
마지막으로 예제이다. 다음 프로그램은 Fistream
객체를 사용하여 표준 입력에 나타나는 url-인코드된 정보를 url-디코드한다.
int main() { Fistream fis(cin); fis >> hex; while (true) { size_t x; switch (x = fis.get()) { case '\n': cout << '\n'; break; case '+': cout << ' '; break; case '%': fis >> field(2) >> x >> field(0); // 최후의 대비책 default: cout << static_cast<char>(x); break; case EOF: return 0; } } } /* 다음 명령어를 실행한 후의 출력: echo My+name+is+%60Ed%27 | a.out My name is `Ed' */
fork
시스템 호출은 유명하다. 프로그램이 프로세스를 새로 시작할 필요가 있다면 system
함수를 사용할 수 있다. system
함수는 프로그램에게 자손 프로세스가 끝나기를 기다리도록 요구한다. 부프로세스를 부화시키는 더 일반적인 방식은 fork
를 사용하는 것이다.
이 절은 어떻게 하면 fork
같은 복잡한 시스템 호출을 C++로 둘러싸 클래스로 포장할 수 있는지 알아본다. 이 절에서 다른 것들은 대부분 유닉스 운영 체제에 직접적으로 적용된다. 그러므로 유닉스 운영체제에 연구의 초점을 둔다. 다른 시스템도 비슷한 편의기능을 제공한다. 다음에 논의하는 것들은 템플릿 디자인 패턴에 밀접하게 관련된다 (참고: Gamma et al. (1995) Design Patterns, Addison-Wesley)
fork
를 호출하면 현재 프로그램이 메모리에 복제된다. 그래서 새로 프로세스를 생성한다. 이렇게 복제한 다음에 두 프로세스는 fork
시스템 호출 아래에서 각자 실행을 계속한다. 두 프로세스는 fork
의 반환 값을 조사할 수 있다. 원래의 부모 프로세스의 반환 값은 새로 생성된 자손 프로세스의 반환 값과 다르다.
fork
는 방금 생성된 (자손) 프로세스의 process ID를 돌려준다. 이것은 양의 정수 값이다.
fork
는 0을 돌려준다.
fork
가 실패하면 -1을 돌려준다.
Fork
클래스는 기본적으로 fork
호출 같은 시스템의 모든 세부 사항을 사용자로부터 감추어야 한다. 클래스 자체는 적절하게 fork
시스템 호출을 실행할 뿐이다. 일반적으로 fork
는 자손 프로세스를 시작하기 위하여 호출된다. 따로따로 프로세스를 실행하는 것이 그 본질이다. 이 자손 프로세스는 표준 입력 스트림에서 입력을 기대하고 표준 출력 또는 에러 스트림에 출력할 수 있다. Fork
는 이 모든 것을 알지 못하며 자손 프로세스가 무엇을 하는지 알 필요도 없다. Fork
객체는 자손 프로세스를 기동시킬 수 있어야 한다.
Fork
의 생성자는 자손 프로세스가 어떤 행위를 수행해야 하는지 알 수 없다. 마찬가지로 부모 프로세스가 무엇을 수행해야 하는지도 알 수 없다. 이런 상황을 위하여 템플릿 메쏘드 디자인 패턴이 개발되었다. 감마(Gamma c.s.)에 따르면 템플릿 메쏘드 디자인 패턴은
``한 연산의 알고리즘의 뼈대를 정의한다. 몇몇 단계는 부클래스에 넘긴다. [이] 템플릿 메쏘드 (디자인 패턴)은 부클래스에게 알고리즘의 특정 단계를 재정의하도록 허용한다. 알고리즘의 구조는 바뀌지 않는다.''
이 디자인 패턴으로 추상 바탕 클래스를 정의할 수 있다. 이미 fork
시스템 호출과 관련된 핵심 단계는 제공하고 fork
시스템 호출의 다른 부분은 부클래스에게 구현을 미룰 수 있다.
Fork
추상 바탕 클래스는 다음의 특징이 있다.
d_pid
데이터 멤버를 정의한다. 부모 프로세스에서 이 데이터 멤버에는 자손 프로세스 id가 담기고 자손 프로세스에서는 값 0이 담긴다. 공개 인터페이스는 두 개의 멤버만 선언한다.
다음은 Fork
의 인터페이스이다.
class Fork { int d_pid; public: virtual ~Fork(); void fork(); protected: int pid() const; int waitForChild(); // 상태를 돌려준다. private: virtual void childRedirections(); virtual void parentRedirections(); virtual void childProcess() = 0; // 순수한 가상 멤버 virtual void parentProcess() = 0; };
protected
) 구역에 선언되고 그래서 파생 클래스만 사용할 수 있다. 다음은 그 비-가상 함수들이다.
pid()
: 파생 클래스에게 시스템 fork
의 반환 값에 접근하도록 허용한다.
inline int Fork::pid() const { return d_pid; }
waitForChild()
: int waitForChild
멤버는 부모 프로세스가 호출하여 자손 프로세스가 완료할 때까지 기다릴 수 있다 (아래에 기술됨). 이 멤버는 클래스 인터페이스에 선언된다. 그의 구현은 다음과 같다.
#include "fork.ih" int Fork::waitForChild() { int status; waitpid(d_pid, &status, 0); return WEXITSTATUS(status); }
이 간단한 구현은 자손의 종료 상태를 부모에게 돌려준다. 호출된 waitpid
시스템 함수는 자손 프로세스가 끝날 때까지 블록된다.
fork
시스템 호출을 사용할 때 부모 프로세스와 자손 프로세스는 언제나 구별되어야 한다. 이 두 프로세스 사이를 구별하는 핵심은 부모 프로세스의 d_pid
가 자손의 프로세스-id가 된다는 것이다. 반면에 자손 프로세스 자체의 d_pid
는 0이 된다. 이 두 프로세스는 언제나 구별되어야 하므로 (그리고 실제로 구별되므로) Fork
로부터 상속받은 클래스로 구현하면 Fork
의 인터페이스가 강제된다. 자손 프로세스의 조치를 정의한 childProcess
멤버와 부모 프로세스의 조치를 정의한 parentProcess
멤버는 순수한 가상 함수로 정의되어 있다.
childRedirections()
: 표준 스트림 (cin, cout,
) 또는 cerr
를 자손 프로세스에서 방향전환해야 한다면 이 멤버를 파생 클래스에서 재정의해야 한다 (24.2.3항). 기본으로 구현은 비어 있다.
parentRedirections()
: 표준 스트림 (cin, cout,
) 또는 cerr
를 부모 프로세스에서 방향전환해야 한다면 이 멤버를 파생 클래스에서 재정의해야 한다 (24.2.3항). 기본으로 구현은 비어 있다.
void Fork::childRedirections() {} void Fork::parentRedirections() {}
fork
멤버 함수는 fork
시스템 함수를 호출한다 (시스템 함수 fork
는 이름이 같은 멤버 함수가 호출하므로 ::
영역 지정 연산자를 사용해야 멤버 함수 자신을 재귀적으로 호출하는 것을 방지할 수 있다). ::fork
함수의 반환 값은 parentProcess
가 호출될지 아니면 childProcess
가 호출될지 결정한다. 아마도 방향전환이 필요할 것이다. Fork::fork
의 구현은 childProcess
를 호출하기 바로 전에 childRedirections
을 호출하고 parentProcess
를 호출하기 바로 전에 parentRedirections
를 호출한다.
#include "fork.ih" void Fork::fork() { if ((d_pid = ::fork()) < 0) throw "Fork::fork() failed"; if (d_pid == 0) // 자손 프로세스는 pid가 0이다. { childRedirections(); childProcess(); exit(1); // 여기까지 오면 안된다. } // childProcess()는 종료한다. parentRedirections(); parentProcess(); }
fork.cc
에서 클래스의 내부 fork.ih
헤더 파일이 포함된다. 이 헤더 파일이 필요한 시스템 파일을 책임지고 포함한다. 뿐만 아니라 fork.h
자체도 포함한다. 그의 구현은 다음과 같다.
#include "fork.h" #include <cstdlib> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h>
자손 프로세스는 반환되면 안 된다. 자신의 일을 끝내면 일단 종료되어야 한다. 자손 프로세스가 exec...
가족의 멤버에 대한 호출을 수행할 때 이런 일이 자동으로 일어난다. 그러나 자손 자체가 여전히 살아 있으면 적절하게 자손이 종료되었는지 확인해야 한다. 자손 프로세스는 exit
을 사용하여 자신을 종료한다. 그러나 exit
는 exit
이 호출된 레벨과 같거나 더 바깥쪽 레벨에 정의된 객체의 소멸자가 활성화되지 못하도록 막는다. 전역적으로 정의된 객체의 소멸자는 exit
이 사용될 때 활성화된다. exit
함수를 사용하여 childProcess
를 끝낼 때, 필요한 모든 내포된 객체를 정의한 지원 멤버 함수를 직접 호출하거나 아니면 모든 객체를 (예를 들어 throw
블록을 사용하여) 복합 서술문 안에 정의한 다음에 exit
함수를 호출해야 한다.
부모 프로세스는 자손이 끝나기를 기다려야 한다. 자손 프로세스는 끝내기 전에 부모 프로세스에게 신호를 보내 알려야 한다. 자손 프로세스가 끝나고 부모 프로세스가 그 신호를 받지 못하면 자손 프로세스가 여전히 보이게 되는데 이를 좀비 프로세스라고 부른다.
부모 프로세스가 자손이 끝나기를 기다려야 한다면 waitForChild
멤버를 호출할 수 있다. 이 멤버는 자손 프로세스의 종료 상태를 부모에게 돌려준다.
자손 프로세스는 계속 살아 있지만 부모는 죽는 경우가 있다. 이것은 자연스러운 사건이다. 부모는 자손 프로세스보다 먼저 죽는 경향이 있다. C++ 세계에서는 이것을 데몬 프로그램이라고 부른다. 데몬에서 부모 프로세스는 죽고 자손 프로그램은 기본 init
프로세스의 자손으로 실행을 계속한다. 다시, 자손이 마침내 죽을 때 신호가 그의 `의붓-부모' init
에게 전송된다. 이것은 좀비를 생성하지 않는다. init
이 자신의 모든 (의붓) 자손들의 종료 신호를 잡기 때문이다. 데몬 프로세스의 생성은 아주 간단하다. Fork
클래스만 있으면 된다 (24.2.4항).
ios::rdbuf
멤버 함수를 사용하여 방향전환된다고 언급했다. 스트림의 streambuf
를 또다른 스트림에 할당함으로써 두 스트림 객체 모두 같은 streambuf
에 접근한다. 그리하여 C++ 언어 자체의 수준에서 방향전환을 구현한다.
이것은 C++ 프로그램의 문맥 안에서는 잘 작동한다. 그러나 이 문맥을 벗어나면 바로 방향전환은 종료한다. 운영 체제는 streambuf
객체를 알지 못한다. 이 상황은 프로그램이 system
호출을 사용하여 부프로그램을 기동시킬 때에도 만난다. 이 절의 끝에 있는 예제 프로그램은 C++ 방향전환을 사용하여 cout
에 삽입되는 정보를 파일로 방향전환한 다음,
system("echo hello world")위와 같이 호출하여 눈에 익은 텍스트 한 줄을 화면에 출력한다.
echo
는 정보를 표준 출력에 쓰기 때문에 운영 체제가 C++의 방향전환을 인지한다면 이것이 바로 프로그램의 방향전환된 파일이 될 것이다.
그러나 방향전환은 일어나지 않는다. 대신에 hello world
는 여전히 프로그램의 표준 출력에 나타나고 방향전환된 파일은 그대로이다. hello world
를 방향전환된 파일에 쓰려면 방향전환을 운영 체제 수준에서 실현해야 한다. 이를 위하여 어떤 운영 체제는 (예를 들어 유닉스와 그 친구들은) dup
와 dup2
같은 시스템 호출을 제공한다. 이런 시스템 호출의 사용 예제는 24.2.5항에 보여준다.
다음은 시스템 수준에서 실패하는 방향전환의 예이다. C++의 streambuf
방향전환을 사용한다.
#include <iostream> #include <fstream> #include <cstdlib> using namespace std; int main() { ofstream of("outfile"); streambuf *buf = cout.rdbuf(of.rdbuf()); cout << "To the of stream\n"; system("echo hello world"); cout << "To the of stream\n"; cout.rdbuf(buf); } /* `outfile' 파일에 출력함: To the of stream To the of stream 표준 출력에 출력함: hello world */
fork
의 유일한 목적은 자손 프로세스를 기동시키는 것이다. 자손 프로세스를 부화시키고 나면 부모 프로세스는 즉시 종료한다. 이런 일이 일어나더라도 자손 프로세스는 init
프로세스의 자손으로 실행을 계속한다. init
프로세스는 유닉스 시스템에서 언제나 제일 먼저 실행되는 첫 프로세스이다. 그런 프로세스를 배경 프로세스로 실행되는 데몬이라고 부른다.
다음 예제는 평범한 C 프로그램으로 어렵지 않게 구성할 수 있지만 C++ 주해서에 포함했다. 왜냐하면 현재 논의 중인 Fork
클래스와 밀접하게 관련이 있기 때문이다. daemon
멤버를 Fork
클래스에 추가할까도 생각했지만 결국 포기하기로 결정했다. 데몬 프로그램의 생성은 아주 간단하며 현재 Fork
가 제공하는 특징들 외에 별다른 특징을 요구하지 않기 때문이다.
다음은 데몬 프로그램의 생성 방법을 시연하는 예제이다. 자손 프로세스는 exit
함수로 종료하지 않고 throw 0
으로 예외를 던진다. 그러면 자손의 main
함수에서 catch
절이 잡는다. 이렇게 하면 자손 프로세스에 정의된 객체는 모두 적절하게 파괴된다는 보장이 있다.
#include <iostream> #include <unistd.h> #include "fork.h" class Daemon: public Fork { virtual void parentProcess() // 부모는 아무것도 하지 않는다. {} virtual void childProcess() // 자손의 행위 { sleep(3); // 그냥 메시지 출력... std::cout << "Hello from the child process\n"; throw 0; // 자손 프로세스 종료 } }; int main() try { Daemon().fork(); } catch(...) {} /* 출력: 다음 명령어를 기다린 다음, 3초 후에 출력된다. Hello from the child process */
pipe
시스템 호출이 생성하는 파일 기술자의 사용을 요구한다. 두 프로세스가 파일 기술자로 서로 통신하고 싶을 때 다음과 같은 일이 일어난다.
pipe
시스템 호출을 사용하여 연관된 파일 기술자 두 개를 생성한다. 파일 기술자 중 하나는 쓰기에 사용되고 다른 파일 기술자는 읽기에 사용된다.
fork
함수가 호출된다). 파일 기술자를 복제한다. 이제 네 개의 파일 기술자가 있다. 자손 프로세스와 부모 프로세스가 각각 pipe
가 생성한 두 개의 파일 기술자 사본을 보유하기 때문이다.
Pipe
같은 클래스 안에 감출 수 있다. 그 특징들을 한 번 살펴 보자 (pipe
와 dup
같은 함수들을 사용하려면 컴파일러는 먼저 <unistd.h>
헤더를 읽어야 한다):
pipe
시스템 호출은 두 개의 int
값을 포인터로 기대한다. 각각 읽기와 쓰기에 사용되는 파일 기술자를 가리킨다. 혼란을 피하기 위해 Pipe
클래스는 enum
을 정의한다. 그 안의 값들은 두 개의 int
인덱스가 심볼 상수에 연관된다. 두 파일 기술자 자체는 d_fd
데이터 멤버에 저장된다. 다음은 클래스 인터페이스의 시작 부분이다.
class Pipe { enum RW { READ, WRITE }; int d_fd[2];
pipe
멤버를 호출하여 연관된 파일 기술자 집합을 생성한다. 파이프의 양단에 접근하기 위하여 사용된다.
Pipe::Pipe() { if (pipe(d_fd)) throw "Pipe::Pipe(): pipe() failed"; }
readOnly
멤버와 readFrom
멤버를 사용하여 파이프의 양단을 구성한다. readFrom
멤버는 방향전환에 사용된다. 이 멤버에는 파이프로부터 읽는 데 사용될 대안 파일 기술자가 제공된다. 이 대안 파일 기술자는 STDIN_FILENO
이다. cin
을 사용하여 파이프로부터 정보를 추출할 수 있다. readOnly
멤버는 파이프의 읽기 단을 구성한다. 짝이 되는 쓰기 단을 닫고 파일 기술자를 돌려준다. 이 기술자를 사용하면 파이프로부터 읽을 수 있다.
int Pipe::readOnly() { close(d_fd[WRITE]); return d_fd[READ]; } void Pipe::readFrom(int fd) { readOnly(); redirect(d_fd[READ], fd); close(d_fd[READ]); }
writeOnly
와 두 개의 writtenBy
멤버는 파이프의 쓰기 단을 구성할 수 있다. writeOnly
멤버는 파이프의 쓰기 단만 구성한다. 읽기 단을 닫고 파일 기술자를 돌려준다. 이 기술자를 사용하면 파이프에 쓸 수 있다.
int Pipe::writeOnly() { close(d_fd[READ]); return d_fd[WRITE]; } void Pipe::writtenBy(int fd) { writtenBy(&fd, 1); } void Pipe::writtenBy(int const *fd, size_t n) { writeOnly(); for (size_t idx = 0; idx < n; idx++) redirect(d_fd[WRITE], fd[idx]); close(d_fd[WRITE]); }
writtenBy
멤버는 두 개의 중복정의 버전이 있다.
redirect
는 dup2
시스템 호출을 통하여 방향전환을 설정한다. 이 멤버는 두 개의 파일 기술자를 기대한다. 첫 번째 파일 기술자는 장치의 정보에 접근할 수 있다. 두 번째 파일 기술자는 보조 파일 기술자이다. 역시 장치의 정보에 접근할 수 있다. 다음은 redirect
의 구현이다.
void Pipe::redirect(int d_fd, int alternateFd) { if (dup2(d_fd, alternateFd) < 0) throw "Pipe: redirection failed"; }
Pipe
객체로 방향전환을 쉽게 구성할 수 있으므로 Fork
와 Pipe
를 다양한 예제 프로그램에 사용해 보자.
Fork
로부터 파생된 ParentSlurp
클래스는 (/bin/ls
처럼) 독립 프로그램을 실행하는 자손 프로세스를 기동시킨다. 화면에는 보이지 않지만 프로그램의 (표준) 출력은 부모 프로세스가 읽는다.
데모의 목적으로 부모 프로세스는 표준 출력 스트림으로부터 받은 줄 앞에다 번호를 붙여서 화면에 쓴다. 부모의 표준 입력 스트림을 방향전환하는 것은 매력적이다. std::cin
입력 스트림을 사용하여 부모는 자손 프로세스로부터 출력을 읽을 수 있기 때문이다. 그러므로 프로그램에서 단 하나의 파이프가 부모에게는 입력 파이프로 그리고 자손에게는 출력 파이프로 사용된다.
ParentSlurp
클래스는 다음의 특징이 있다.
Fork
로부터 파생된다. ParentSlurp
클래스의 인터페이스를 시작하기 전에 컴파일러는 fork.h
와 pipe.h
를 미리 읽어야 한다. 이 클래스는 데이터 멤버로 Pipe
실체 d_pipe
하나만 사용한다.
Pipe
의 생성자는 이미 파이프를 정의했으므로 그리고 d_pipe
는 묵시적으로 제공된 ParentSlurp
의 기본 생성자가 자동으로 초기화하므로 다른 모든 멤버는 오직 ParentSlurp
만을 위해 존재한다. 그래서 클래스의 (묵시적인) 비밀 구역에 정의할 수 있다. 다음은 그의 클래스 인터페이스이다.
class ParentSlurp: public Fork { Pipe d_pipe; virtual void childRedirections(); virtual void parentRedirections(); virtual void childProcess(); virtual void parentProcess(); };
childRedirections
멤버는 파이프의 쓰기 단을 구성한다. 그래서 자손의 표준 출력 스트림에 씌여지는 모든 정보는 결국 파이프에 나타난다. 그러므로 파일 기술자에 쓰기 위해 스트림이 따로 더 필요하지 않다는 큰 장점이 있다.
inline void ParentSlurp::childRedirections() { d_pipe.writtenBy(STDOUT_FILENO); }
parentRedirections
멤버는 파이프의 읽기 단을 구성한다. 파이프의 읽기 단을 부모의 표준 입력 파일 기술자에 연결한다 (STDIN_FILENO
). 이렇게 하면 부모는 cin
으로부터 추출할 수 있다. 읽기 위해 추가 스트림이 필요없다.
inline void ParentSlurp::parentRedirections() { d_pipe.readFrom(STDIN_FILENO); }
childProcess
멤버는 자신의 일에만 집중한다. 프로그램만 실행하면 되기 때문에 (정보를 표준 출력에 쓰기만 하면 되므로) 이 멤버는 단 한 줄의 서술문으로 구성할 수 있다.
inline void ParentSlurp::childProcess() { execl("/bin/ls", "/bin/ls", 0); }
parentProcess
멤버는 표준 입력에 나타나는 정보를 그냥 `빨아 들인다'. 그렇게 하면 실제로는 자손의 출력을 읽는 것이다. 받은 줄에다 줄번호를 붙여서 하나씩 표준 출력 스트림에 복사한다.
void ParentSlurp::parentProcess() { std::string line; size_t nr = 1; while (getline(std::cin, line)) std::cout << nr++ << ": " << line << '\n'; waitForChild(); }
ParentSlurp
객체를 생성한다. 그리고 자신의 fork()
멤버를 호출한다. 프로그램이 시작된 디렉토리에 있는 파일이름에 줄번호를 붙여서 리스트를 출력한다. 프로그램은 또 fork.o
와 pipe.o
그리고 waitforchild.o
오브젝트 파일도 필요함에 주목하라 (이전 소스 참고):
int main() { ParentSlurp().fork(); } /* 출력 (예제용일 뿐임, 실제 출력은 컴퓨터마다 다름): 1: a.out 2: bitand.h 3: bitfunctional 4: bitnot.h 5: daemon.cc 6: fdinseek.cc 7: fdinseek.h ... */
start
:
새 자손 프로세스를 기동한다. 부모는 자손의 ID (번호)를 사용자에게 돌려준다. 그 ID는 이후로 특정한 자손 프로세스를 식별한다.
<nr> text
ID가 <nr>
인 자손 프로세스에 ``text
''를 전송한다.
stop <nr>
ID가 <nr>
인 자손 프로세스를 종료한다.
exit
부모는 물론이고 자손 프로세스도 종료한다.
우리의 관제 프로그램에서 문제는 여러 소스로부터 비동기 입력이 허용된다는 것이다. 입력은 표준 입력은 물론이고 파이프의 입력 단에서도 일어날 수 있다. 또한 여러 출력 채널이 사용된다. 이와 같은 상황을 처리하기 위해 select
시스템 호출이 개발되었다.
select
시스템 호출은 비동기 I/O 다중화(멀티플렉싱)을 처리하기 위하여 개발되었다. select
시스템 호출은 파일 기술자 집합에서 동시다발적으로 일어나는 입력을 처리한다.
select
함수는 좀 복잡하다. 이 함수를 완전하게 논의하는 것은 이 책의 범위를 벗어난다. select
를 class Selector
에 싸 넣음으로써 세부사항들을 감추고 직관적으로 단순한 인터페이스를 제공하면 사용법은 간단해진다. Selector
클래스는 다음의 특징이 있다.
Select
의 멤버는 아주 작기 때문에 대부분 인라인으로 구현할 수 있다. 데이터 멤버를 좀 많이 요구한다. 이 데이터 멤버들이 속한 유형은 대부분 시스템 헤더를 먼저 포함하기를 요구한다.
#include <limits.h> #include <unistd.h> #include <sys/time.h> #include <sys/types.h>
fd_set
데이터 유형은 select
에 사용되도록 설계된 유형이다. 이 유형의 변수들은 파일 기술자 집합을 담고 있다. 거기에 select
는 어떤 행위를 감지할 수 있다. 그리고 select
는 비동기적으로 경고를 촉발시킬 수 있다. 경고 시각을 설정하기 위해 Selector
클래스는 timeval
데이터 멤버를 정의한다. 다른 멤버들은 내부 기록용으로 사용된다. 다음은 Selector
의 인터페이스 구현이다.
class Selector { fd_set d_read; fd_set d_write; fd_set d_except; fd_set d_ret_read; fd_set d_ret_write; fd_set d_ret_except; timeval d_alarm; int d_max; int d_ret; int d_readidx; int d_writeidx; int d_exceptidx; public: Selector(); int exceptFd(); int nReady(); int readFd(); int wait(); int writeFd(); void addExceptFd(int fd); void addReadFd(int fd); void addWriteFd(int fd); void noAlarm(); void rmExceptFd(int fd); void rmReadFd(int fd); void rmWriteFd(int fd); void setAlarm(int sec, int usec = 0); private: int checkSet(int *index, fd_set &set); void addFd(fd_set *set, int fd); };
Selector()
: (기본) 생성자이다. 읽기 쓰기를 비우고 fd_set
변수를 실행하고 경고를 끈다. d_max
를 제외하고 나머지 데이터 멤버들은 특정한 초기화를 요구하지 않는다.
Selector::Selector() { FD_ZERO(&d_read); FD_ZERO(&d_write); FD_ZERO(&d_except); noAlarm(); d_max = 0; }
int wait()
: 이 멤버는 경고 시간이 지나거나 Selector
객체가 감시하는 파일 기술자 중에 행위가 감지될 때까지 블록된다. select
시스템 호출 자체가 실패하면 예외를 던진다.
int Selector::wait() { timeval t = d_alarm; d_ret_read = d_read; d_ret_write = d_write; d_ret_except = d_except; d_readidx = 0; d_writeidx = 0; d_exceptidx = 0; d_ret = select(d_max, &d_ret_read, &d_ret_write, &d_ret_except, t.tv_sec == -1 && t.tv_usec == -1 ? 0 : &t); if (d_ret < 0) throw "Selector::wait()/select() failed"; return d_ret; }
int nReady()
: 이 멤버 함수의 반환 값은 오직 wait
가 돌아올 때만 정의된다. 그 경우 경고-제한 시간이 지나면 0을 돌려주고 select
가 실패하면 -1을 돌려주며 그렇지 않으면 행위가 감지된 파일 기술자의 갯수를 돌려준다.
inline int Selector::nReady() { return d_ret; }
int readFd()
: 이 멤버 함수의 반환 값도 wait
가 반환된 이후에만 정의된다. (더 이상) 입력 파일 기술자가 없으면 반환 값은 -1이다. 그렇지 않으면 다음 읽기용 파일 기술자를 돌려준다.
inline int Selector::readFd() { return checkSet(&d_readidx, d_ret_read); }
int writeFd()
: readFd
와 비슷하게 작동한다. 출력이 씌여질, 다음 파일 기술자를 돌려준다. d_writeidx
와 d_ret_read
를 사용하고 readFd
와 유사하게 구현되었다.
int exceptFd()
: readFd
와 유사하게 작동한다. 행위가 감지된, 다음 예외 파일 기술자를 돌려준다. d_except_idx
와 d_ret_except
를 사용하고 readFd
와 비슷하게 구현되었다.
void setAlarm(int sec, int usec = 0)
: 이 멤버는 Select
의 경고 기능을 활성화한다. 적어도 경고 기능이 꺼질 때까지 기다려야 할 초의 갯수를 지정해야 한다. 값들을 d_alarm
에 할당하기만 한다. 다음 Select::wait
호출에서 구성된 경고-간격을 지나면 경고가 촉발된다 (즉, wait
가 반환 값 0을 가지고 돌아온다):
inline void Selector::setAlarm(int sec, int usec) { d_alarm.tv_sec = sec; d_alarm.tv_usec = usec; }
void noAlarm()
: 이 멤버는 경고 기능을 꺼버린다. 그냥 경고 간격에 아주 긴 시간을 설정한다.
inline void Selector::noAlarm() { setAlarm(-1, -1); }
void addReadFd(int fd)
: 이 멤버는 Selector
객체가 관제하는 입력 파일 기술자 집합에 파일 기술자를 추가한다. 지시된 파일 기술자에 입력이 가능하면 wait
멤버 함수가 반환된다.
inline void Selector::addReadFd(int fd) { addFd(&d_read, fd); }
void addWriteFd(int fd)
: 이 멤버는 파일 기술자를 Selector
가 관제하는 파일 기술자 집합에 추가한다. 지시된 파일 기술자에 출력이 가능하면 wait
가 반환된다. d_write
멤버를 사용하여 addReadFd
와 유사하게 구현되었다.
void addExceptFd(int fd)
: 이 멤버는 파일 기술자를 Selector
객체가 관제하는 예외 파일 기술자 집합에 추가한다. 지시된 파일 기술자에 행위가 감지되면 wait
멤버 함수가 반환된다. d_except
멤버를 사용하여 addReadFd
와 비슷하게 구현되었다.
void rmReadFd(int fd)
: 이 멤버는 파일 기술자를 Selector
가 관제하는 입력 파일 기술자 집합으로부터 제거한다.
inline void Selector::rmReadFd(int fd) { FD_CLR(fd, &d_read); }
void rmWriteFd(int fd)
: 이 멤버는 Selector
가 관제하는 출력 파일 기술자 집합으로부터 파일 기술자를 제거한다. d_write
멤버를 사용하여 rmReadFd
와 유사하게 구현되었다.
void rmExceptFd(int fd)
: 이 멤버는 Selector
가 관제하는 예외 파일 기술자로부터 파일 기술자를 제거한다. d_except
멤버를 사용하여 rmReadFd
와 비슷하게 구현되었다.
addFd
멤버는 파일 기술자를 fd_set
에 추가한다.
void Selector::addFd(fd_set *set, int fd) { FD_SET(fd, set); if (fd >= d_max) d_max = fd + 1; }
checkSet
멤버는 파일 기술자가 (*index
) fd_set
에 있는지 테스트한다.
int Selector::checkSet(int *index, fd_set &set) { int &idx = *index; while (idx < d_max && !FD_ISSET(idx, &set)) ++idx; return idx == d_max ? -1 : idx++; }
Monitor
객체를 사용한다. 이 객체가 대부분의 작업을 맡는다. 자신의 일을 수행하기 위하여 Monitor
클래스의 공개 인터페이스는 기본 생성자와 run
멤버만 제공한다. 다른 모든 멤버 함수는 비밀 구역에 위치한다.
Monitor
는 비밀 Commands
열거체를 정의한다. 이 열거체에는 입력 명령어가 지원하는 다양한 명령어들이 심볼로 나열되어 있다. 뿐만 아니라 여러 데이터 멤버도 정의한다. 데이터 멤버 중에서 Selector
객체와 map
객체는 자손 순서 번호를 키로 사용하고 Child
객체를 가리키는 포인터를 값으로 사용한다 (24.2.7.7목). 그리고 Monitor
는 정적 배열 s_handler[]
멤버가 있다. 이 멤버는 사용자 명령어를 처리하는 멤버 함수를 포인터로 저장한다.
소멸자도 구현해야 한다. 그러나 그 구현은 독자 여러분에게 연습 문제로 남긴다. 다음은 Monitor
의 인터페이스이다. 함수객체를 생성하는 Find
내포 클래스의 인터페이스도 제시한다.
class Monitor { enum Commands { UNKNOWN, START, EXIT, STOP, TEXT, sizeofCommands }; typedef std::map<int, std::shared_ptr<Child>> MapIntChild; friend class Find; class Find { int d_nr; public: Find(int nr); bool operator()(MapIntChild::value_type &vt) const; }; Selector d_selector; int d_nr; MapIntChild d_child; static void (Monitor::*s_handler[])(int, std::string const &); static int s_initialize; public: enum Done {}; Monitor(); void run(); private: static void killChild(MapIntChild::value_type it); static int initialize(); Commands next(int *value, std::string *line); void processInput(); void processChild(int fd); void createNewChild(int, std::string const &); void exiting(int = 0, std::string const &msg = std::string()); void sendChild(int value, std::string const &line); void stopChild(int value, std::string const &); void unknown(int, std::string const &); };
클래스 유형이 아닌 데이터 멤버가 하나만 있기 때문에 클래스의 생성자는 인라인으로 구현해도 되는 아주 간단한 함수이다.
inline Monitor::Monitor() : d_nr(0) {}
s_handler
배열은 함수를 가리키는 포인터들을 담고 있는데 역시 초기화할 필요가 있다. 여러가지 방식으로 달성할 수 있다.
Commands
열거체는 명령어 집합이 상당히 제한적이므로 컴파일 시간 초기화를 고려할 수 있다.
void (Monitor::*Monitor::s_handler[])(int, string const &) = { &Monitor::unknown, // Command 열거체의 &Monitor::createNewChild, // 원소들 순서를 따른다. &Monitor::exiting, &Monitor::stopChild, &Monitor::sendChild, };
이 방식의 장점은 간단하다는 것이다. 실행 시간 노력이 전혀 필요없다. 물론 단점은 상대적으로 관리하기가 복잡하다는 것이다. 어떤 이유로 Commands
가 변경되면 s_handler
도 역시 변경해야 한다. 이와 같은 경우, 컴파일 시간 초기화가 종종 문제를 일으킨다. 그렇지만 간단한 대안이 있다.
Monitor
의 인터페이스에 정적 s_initialize
데이터 멤버와 정적 initialize
멤버 함수가 보인다. initialize
멤버는 s_handler
배열의 초기화를 처리한다. 명시적으로 배열의 원소들을 할당한다. 그리고 enum Commands
의 값들의 순서가 조금이라도 변경되면 initialize
를 재컴파일해 자동으로 반영한다.
void (Monitor::*Monitor::s_handler[sizeofCommands])(int, string const &); int Monitor::initialize() { s_handler[UNKNOWN] = &Monitor::unknown; s_handler[START] = &Monitor::createNewChild; s_handler[EXIT] = &Monitor::exiting; s_handler[STOP] = &Monitor::stopChild; s_handler[TEXT] = &Monitor::sendChild; return 0; }
initialize
멤버는 정적 멤버이다. 그래서 정적 int
변수인 s_initialize
를 초기화하기 위해 호출할 수 있다. 실행될 것이라고 알려진 함수의 소스 파일에 초기화 서술문을 배치함으로서 강제로 초기화한다. 그것이 main
일 수도 있지만 우리가 Monitor
를 유지관리하는 책임을 지고 있고 Monitor
의 코드가 담긴 라이브러리만 통제권이 있다면 그것은 선택이 될 수 없다. 그런 경우, 소멸자가 담긴 소스 파일이 아주 훌륭한 후보가 된다. 클래스에 생성자가 하나만 있고 그것이 인라인으로 정의되어 있지 않다면 그 생성자의 소스 파일도 역시 좋은 후보감이다. Monitor
의 현재 구현에서 초기화 서술문은 run
의 소스 파일에 배치되어 있다. 그러므로 run
이 사용될 때 s_handler
만 필요하다고 추론할 수 있다.
Monitor
객체의 핵심 조치는 run
멤버가 수행한다.
Monitor
객체는 표준 입력만 관제한다. d_selector
멤버가 청취하는 입력 파일 기술자 집합은 STDIN_FILENO
으로 초기화된다.
d_selector
의 wait
함수가 호출된다. cin
에 입력이 있으면 processInput
함수가 처리한다. 그렇지 않으면 그 입력은 자손 프로세스로부터 부모 프로세스에 도착한다. 자손이 보낸 정보는 processChild
함수가 처리한다.
벤 사이먼(Ben Simons (ben at mrxfx dot com
))이 지적했듯이 Monitor
는 종료 신호를 잡으면 안된다. 대신에 그 책임은 자손 프로세스를 부화시킨 프로세스에게 있다 (아래에 깔린 원칙은 다음과 같다. 부모 프로세스는 자신의 자손 프로세스를 책임지고 이어서 자손 프로세스는 또 자신의 자손 프로세스에 책임이 있다).
run
의 소스 파일에 s_initialize
가 정의되어 있다. 이를 초기화하여 s_handler
배열을 적절하게 초기화한다.
run
의 구현과 s_initialize
의 정의이다.
#include "monitor.ih" int Monitor::s_initialize = Monitor::initialize(); void Monitor::run() { d_selector.addReadFd(STDIN_FILENO); while (true) { cout << "? " << flush; try { d_selector.wait(); int fd; while ((fd = d_selector.readFd()) != -1) { if (fd == STDIN_FILENO) processInput(); else processChild(fd); } cout << "NEXT ...\n"; } catch (char const *msg) { exiting(1, msg); } } }
processInput
멤버 함수는 프로그램의 표준 입력 스트림으로 사용자가 입력한 명령어를 읽는다. 멤버 자체는 단순하다. next
멤버를 호출하여 사용자가 입력한, 다음 명령어를 얻는다. 그 다음에 s_handler[]
배열에서 해당되는 원소에 상응하는 함수를 호출한다. 다음은 processInput
멤버와 next
멤버이다.
void Monitor::processInput() { string line; int value; Commands cmd = next(&value, &line); (this->*s_handler[cmd])(value, line); }
Monitor::Commands Monitor::next(int *value, string *line) { if (!getline(cin, *line)) exiting(1, "Monitor::next(): reading cin failed"); if (*line == "start") return START; if (*line == "exit" || *line == "quit") { *value = 0; return EXIT; } if (line->find("stop") == 0) { istringstream istr(line->substr(4)); istr >> *value; return !istr ? UNKNOWN : STOP; } istringstream istr(line->c_str()); istr >> *value; if (istr) { getline(istr, *line); return TEXT; } return UNKNOWN; }
d_select
가 감지한 다른 모든 입력은 자손 프로세스가 생성한다. d_select
의 readFd
멤버는 상응하는 입력 파일 기술자를 돌려주므로 이 기술자를 processChild
에 건넬 수 있다. IFdStreambuf
로 그의 정보를 입력 스트림으로부터 읽는다 (24.1.2.1목). 여기에 사용된 통신 프로토콜은 단순하다. 줄이 입력될 때마다 자손은 정확하게 한 줄의 텍스트를 되돌려 줌으로써 응답한다. 그러면 이 줄을 processChild
가 읽는다.
void Monitor::processChild(int fd) { IFdStreambuf ifdbuf(fd); istream istr(&ifdbuf); string line; getline(istr, line); cout << d_child[fd]->pid() << ": " << line << '\n'; }
위의 소스에 사용된 d_child[fd]->pid()
의 생성 방법은 특별히 주의를 기울일 가치가 있다. Monitor
는 map<int, shared_ptr<Child>> d_child
데이터 멤버를 정의한다. 이 멤버에는 자손의 순서 번호가 키로 들어 있고 Child
객체를 가리키는 (공유) 포인터가 값으로 들어 있다. 여기에서는 Child
객체가 아니라 공유 포인터를 사용한다. 맵이 제공하는 편의기능들을 사용하고는 싶지만 Child
객체를 되풀이해서 복사하고 싶지는 않기 때문이다.
run
의 구현을 다루어 보았으므로 사용자가 입력할 가능성이 있는 다양한 명령어에 집중해 보겠다.
start
명령어가 제출되면 새 자손 프로세스가 기동된다. createNewChild
멤버가 새 원소를 d_child
에 추가한다. 다음으로 Child
객체는 일을 시작해야 한다. 그러나 Monitor
객체는 자손 프로세스가 일을 끝내기를 기다릴 수 없다. 정의가 잘 된 끝점이 가까운 미래에 없기 때문이다. 그리고 사용자는 어쩌면 더 많은 명령어를 입력하고 싶을 수도 있다. 그러므로 Child
프로세스는 데몬으로 실행해야 한다. 그래서 분기된 프로세스는 즉시 종료하지만 그의 자손 프로세스는 (배경에서) 실행을 계속한다. 결론적으로 createNewChild
는 자손의 fork
멤버를 호출한다. 자손의 fork
함수이지만 여전히 모니터 프로그램이다. 그 안에서 fork
함수가 호출되기 때문이다. 그래서 모니터 프로그램은 fork
때문에 중복된다. 실행은 계속된다.
Child
의 parentProcess
에 실행되고,
Child
의 childProcess
에 실행된다.
Child
의 parentProcess
는 즉시 돌아오는 빈 함수이므로 Child
의 부모 프로세스는 createNewChild
의 cp->fork()
서술문 바로 아래에서 실행이 계속되는 효과가 있다. 자손 프로세스는 절대로 돌아오지 않기 때문에 (24.2.7.7목) cp->fork()
아래에 있는 코드는 Child
의 자손 프로세스에 의하여 절대로 실행되지 않는다. 이것은 정확하게 수행되어야 하는 그대로이다.
부모 프로세스에서 createNewChild
의 나머지 코드는 자손으로부터 읽은 정보에 사용할 파일 기술자를 d_select
가 관제하는 입력 파일 기술자 집합에 추가한다. 그리고 d_child
멤버로 파일 기술자와 Child
객체의 주소 사이에 연관 관계를 확립한다.
void Monitor::createNewChild(int, string const &) { Child *cp = new Child(++d_nr); cp->fork(); int fd = cp->readFd(); d_selector.addReadFd(fd); d_child[fd].reset(cp); cerr << "Child " << d_nr << " started\n"; }
stop <nr>
과 <nr> text
명령어는 자손과의 직접 통신을 요구한다. 앞의 명령어는 stopChild
멤버를 호출하여 <nr>
자손 프로세스를 끝낸다. Monitor
안에 내포된 Find
클래스의 익명 실체로 이 함수는 해당 순서 번호를 가진 자손 프로세스를 찾는다. Find
클래스는 제공된 nr
을 nr
멤버가 돌려준 자손의 순서 번호와 비교한다.
inline Monitor::Find::Find(int nr) : d_nr(nr) {} inline bool Monitor::Find::operator()(MapIntChild::value_type &vt) const { return d_nr == vt.second->nr(); }
순서 번호가 nr
인 자손 프로세스가 없으면 그의 파일 기술자는 d_selector
의 입력 파일 기술자 집합으로부터 제거된다. 다음, 자손 프로세스 자체가 killChild
정적 멤버에 의하여 종료된다. killChild
멤버는 정적 멤버 함수로 선언된다. for_each
총칭 알고리즘의 exiting
함수에 인자로 사용되기 때문이다 (아래 참고). 다음은 killChild
의 구현이다.
void Monitor::killChild(MapIntChild::value_type it) { if (kill(it.second->pid(), SIGTERM)) cerr << "Couldn't kill process " << it.second->pid() << '\n'; // 사망한 자손 프로세스를 거두어 들인다. int status = 0; while( waitpid( it.second->pid(), &status, WNOHANG) > -1) ; }
지정된 자손 프로세스를 끝냈으므로 그에 상응하는 Child
객체가 파괴되고 그의 포인터들은 d_child
으로부터 제거된다.
void Monitor::stopChild(int nr, string const &) { auto it = find_if(d_child.begin(), d_child.end(), Find(nr)); if (it == d_child.end()) cerr << "No child number " << nr << '\n'; else { d_selector.rmReadFd(it->second->readFd()); d_child.erase(it); } }
<nr> text
명령어는 text
를 자손 프로세스 nr
에 sendChild
멤버 함수로 전송한다. 이 함수는 또한 Find
객체를 사용하여 순서 번호가 nr
인 자손 프로세스를 찾는다. 그리고 그 텍스트를 자손 프로세스에 연결된 파이프의 쓰기 단에 삽입한다.
void Monitor::sendChild(int nr, string const &line) { auto it = find_if(d_child.begin(), d_child.end(), Find(nr)); if (it == d_child.end()) cerr << "No child number " << nr << '\n'; else { OFdnStreambuf ofdn(it->second->writeFd()); ostream out(&ofdn); out << line << '\n'; } }
exit
이나 quit
을 입력하면 exiting
멤버가 호출된다. 이 멤버는 모든 자손 프로세스를 끝낸다. for_each
총칭 알고리즘을 사용하여 d_child
의 모든 원소를 차례차례 방문해서 종료시킨다 (19.1.17항). 그리고 프로그램 자체가 끝난다.
void Monitor::exiting(int value, string const &msg) { for_each(d_child.begin(), d_child.end(), killChild); if (msg.length()) cerr << msg << '\n'; throw value; }
main
함수는 단순하고 주석도 따로 더 필요없다.
int main() try { Monitor().run(); } catch (int exitValue) { return exitValue; }
Monitor
객체가 자손 프로세스를 기동시킬 때 Child
클래스의 객체를 생성한다. Child
클래스는 Fork
클래스로부터 파생되므로 (이전 절에서 논의한 바와 같이) 데몬처럼 작동할 수 있다. Child
는 데몬 클래스이므로 그의 부모 프로세스는 빈 함수로 구현되어 있을 것임에 틀림없다. childProcess
멤버는 구현이 비어 있지 않다. 다음은 Child
클래스의 특징이다.
Child
클래스는 두 개의 Pipe
데이터 멤버가 있다. 자손 프로세스와 부모 프로세스 사이의 통신을 처리한다. 이 파이프들은 Child
의 자손 프로세스가 사용하므로 그 이름들은 자손 프로세스를 따른다. 자손 프로세스는 d_in
으로부터 읽고 d_out
에 쓴다. 다음은 Child
클래스의 인터페이스이다.
class Child: public Fork { Pipe d_in; Pipe d_out; int d_parentReadFd; int d_parentWriteFd; int d_nr; public: Child(int nr); virtual ~Child(); int readFd() const; int writeFd() const; int pid() const; int nr() const; private: virtual void childRedirections(); virtual void parentRedirections(); virtual void childProcess(); virtual void parentProcess(); };
Child
의 생성자는 자손 프로세스의 순서 번호인 인자를 자신의 d_nr
데이터 멤버에 저장하는 일만 한다.
inline Child::Child(int nr) : d_nr(nr) {}
Child
의 자손 프로세스는 표준 입력 스트림으로부터 명령어를 얻고 그 결과를 표준 출력 스트림에 쓴다. 실제 통신 채널은 파이프이므로 방향전환을 사용해야 한다. childRedirections
멤버는 모습이 다음과 같다.
void Child::childRedirections() { d_in.readFrom(STDIN_FILENO); d_out.writtenBy(STDOUT_FILENO); }
d_in
에 쓰고 d_out
으로부터 읽는다. 다음은 parentRedirections
이다.
void Child::parentRedirections() { d_parentReadFd = d_out.readOnly(); d_parentWriteFd = d_in.writeOnly(); }
Child
객체는 Monitor
의 stopChild
멤버가 파괴할 때까지 생존한다. 자신의 창조자인 Monitor
객체에게 부모측 파이프의 단에 접근하도록 허용함으로써 Monitor
객체는 Child
의 자손 프로세스와 파이프-단을 통하여 통신할 수 있다. Monitor
객체는 readFd
멤버와 writeFd
멤버로 파이프-단에 접근할 수 있다.
inline int Child::readFd() const { return d_parentReadFd; } inline int Child::writeFd() const { return d_parentWriteFd; }
Child
객체의 자손 프로세스는 두 가지 일을 수행한다.
childProcess
는 지역 Selector
객체를 정의한다. 자신이 관제하고 있는 입력 파일 기술자의 집합에 STDIN_FILENO
를 추가한다.
다음, 끝없는 회돌이 안에서 childProcess
는 selector.wait()
가 반환되기를 기다린다. 경고 기능이 꺼질 때 메시지를 표준 출력에 (즉, 쓰기 파이프 안으로) 전송한다. 그렇지 않으면 표준 입력에 나타나는 메시지를 표준 출력에 반향한다. 다음은 childProcess
멤버이다.
void Child::childProcess() { Selector selector; size_t message = 0; selector.addReadFd(STDIN_FILENO); selector.setAlarm(5); while (true) { try { if (!selector.wait()) // 시간 제한 cout << "Child " << d_nr << ": standing by\n"; else { string line; getline(cin, line); cout << "Child " << d_nr << ":" << ++message << ": " << line << '\n'; } } catch (...) { cout << "Child " << d_nr << ":" << ++message << ": " << "select() failed" << '\n'; } } exit(0); }
Monitor
객체가 Child
의 프로세스 ID와 그의 순서 번호를 얻도록 하기 위해 두 개의 접근자를 정의한다.
inline int Child::pid() const { return Fork::pid(); } inline int Child::nr() const { return d_nr; }
stop
명령어를 입력하면 Child
프로세스는 종료한다. 기존의 자손 프로세스 번호가 입력되면 상응하는 Child
객체가 Monitor
의 d_child
맵으로부터 제거된다. 결과적으로 그의 소멸자가 호출된다. Child
의 소멸자는 자손을 종료시키 위해 kill
을 호출한다. 다음, 자손이 종료하기를 기다린다. 자손이 종료하면 소멸자는 자신의 일을 마치고 돌아온다. 그리하여 d_child
으로부터의 제거 작업이 완료된다. 현재 구현은 자손 프로세스가 SIGTERM
신호에 응답하지 못하면 실패한다. 이 데모 프로그램에서 이런 일은 일어나지 않는다. `실 세계'라면 좀 더 우아하게 프로세스를 종료시키는 법이 필요할 것이다 (SIGTERM
말고도 SIGKILL
을 사용하는 방법이 있다). 10.12절에서 논의했듯이 적절하게 파괴하는 것이 중요하다. 다음은 Child
의 소멸자이다.
Child::~Child() { if (pid()) { cout << "Killing process " << pid() << "\n"; kill(pid(), SIGTERM); int status; wait(&status); } }
빠진 연산자들이 있는 듯하다. 비트 연산에 상응하는 함수객체들은 미리 정의되어 있지 않은 것 같다. 그렇지만 미리 정의된 객체들이 있으므로 별 어려움 없이 구현할 수 있다. 다음 예제는 비트 연산자를 (operator&
) 호출하는 함수객체를 구현한 클래스 템플릿을 보여주고 단항 부인 연산자를 (operator~
) 호출하는 함수객체를 구현한 클래스 템플릿을 보여준다. 비슷한 함수객체를 다른 연산자에 구현하는 것은 독자 여러분에게 숙제로 남긴다.
다음은 비트 operator&
연산자를 호출하는 함수객체의 구현이다.
#include <functional> template <typename _Tp> struct bitAnd: public std::binary_function<_Tp, _Tp, _Tp> { _Tp &&operator()(_Tp const &__x, _Tp const &__y) const { return std::move(__x & __y); } };
다음은 단항 부인 연산자 operator~()
를 호출하는 함수객체의 구현이다.
#include <functional> template <typename _Tp> struct bit_not: public std::unary_function<_Tp, _Tp> { _Tp &&operator()(_Tp const &__x) const { return _Tp(~__x); } };
미리 정의된 함수객체에서 이리 저리 빠진 객체들은 bitfunctional
파일에 구현되어 있다. 이 파일은 cplusplus.yo.zip
압축 파일에 있다. 이 클래스들은 기존의 클래스 템플릿으로부터 파생된다 (예를 들어 std::binary_function
그리고 std::unary_function
). 이 바탕 클래스들은 STL에 정의된 다양한 총칭 알고리즘이 기대하고 (사용하는) 여러 유형을 정의한다 (제 19장). 그래서 C++ 헤더 파일 bits/stl_function.h
에 제시된 조언을 따른다.
* 표준 함수객체는 unary_function 구조체와 binary_function 구조체로부터 파생된다. * 이 두 클래스에는 총칭 (템플릿) 프로그래밍을 돕기 위한 * 유형정의(typedef) 외에는 아무것도 들어 있지 않다. 여러분 만의 함수객체를 * 구현하려면 똑같은 원칙을 지켜야 할 것이다.
다음은 bit_and
를 사용하는 예제이다. int
값으로 구성된 벡터로부터 홀수를 모두 제거한다.
#include <iostream> #include <algorithm> #include <vector> #include <iterator> #include "bitand.h" using namespace std; int main() { vector<int> vi; for (int idx = 0; idx < 10; ++idx) vi.push_back(idx); copy ( vi.begin(), remove_if(vi.begin(), vi.end(), bind2nd(bitAnd<int>(), 1)), ostream_iterator<int>(cout, " ") ); cout << '\n'; } /* 출력: 0 2 4 6 8 */
const &
인자를 기대하는 이항 연산자는 이동-인지 클래스에 구현할 수 있다. 이동-인지 이항 연산자를 사용하고 첫 인자에 rvalue 참조를 사용하면 된다. 이어서 이 함수는 이항 할당 멤버를 사용하여 구현할 수 있다. 다음 예제는 지어낸 Binary
클래스에 대하여 이 접근법을 보여준다.
class Binary { public: Binary(); Binary(int value); // 복사 생성자와 이동 생성자는 기본으로 사용할 수 있다. // 아니면 명시적으로 선언하고 구현하면 된다. Binary &operator+=(Binary const &other); // 본문 참조 }; inline Binary &&operator+(Binary &&lhs, Binary const &rhs) { return std::move(lhs += rhs); // 복사/이동 생성을 회피한다. } inline Binary &&operator+(Binary const &lhs, Binary const &rhs) { Binary tmp(lhs); return std::move(tmp) + rhs; }
그러므로 이항 연산자의 구현은 결국 이항 할당 연산자를 사용할 수 있는가에 달려 있다.
템플릿 함수는 실제로 사용되기 전에는 구체화되지 않기 때문에 절대로 구체화되지 않는 비-존재 함수를 템플릿에 언급할 수 있다. 그런 함수가 실제로 호출되면 빠진 함수 때문에 컴파일러는 에러 메시지로 응답할 것이다.
이렇게 하면 이동 가능한 이항 연산자와 이동 불가능한 이항 연산자를 모두 템플릿으로 구현할 수 있다. 그러면 짝이 되는 이항 할당이 존재할 경우에만 이항 연산자를 호출할 수 있다. 위의 덧셈 이항 연산자를 구현한 템플릿 함수는 모습이 다음과 같다.
#ifndef INCLUDED_BINOPS_H_ #define INCLUDED_BINOPS_H_ #include <utility> template <typename Type> Type operator+(Type &&lhs, Type const &rhs) { return lhs += rhs; } template <typename Type> Type operator+(Type const &lhs, Type const &rhs) { Type tmp(lhs); return operator+(std::move(tmp), rhs); } #endif
단점이 있다. 이런 함수 템플릿을 정의할 때 꼭 확인하자. rvalue 참조를 첫 매개변수로 지정한 이항 연산자는 상수 lrvalue 참조를 첫 매개변수로 지정한 이항 연산자보다 먼저 정의해야 한다. 그렇지 않으면 이 템플릿을 사용하는 프로그램은 무한 재귀에 빠져 실패한다.
다른 이항 연산자에 대한 함수 템플릿은 이런 덧셈 연산자에 쉽게 추가할 수 있다. binops.h
파일에 모두 모은 후에 이 파일을 클래스 헤더 파일에 포함시켜 이항 연산자들을 추가하면 된다.
흥미롭게도 이동 생성자를 구현하지 않은 클래스도 이 템플릿을 사용할 수 있다. 이동 생성자 자체는 이항 연산자 구현에서 절대로 호출되지 않기 때문이다 (그의 호출은 복사 생략으로 최적화된다). 다음 프로그램은 (출력 서술문이 담긴 수정된 함수 템플릿을 사용하므로) 이동 생성자가 정의되어 있든 없든 똑같이 행위한다.
#include <iostream> using namespace std; template <typename Class> Class &&operator+(Class &&lhs, Class const &rhs) { cout << "operator+(Class &&lhs, Class const &rhs)\n"; return std::move(lhs += rhs); } template <typename Class> Class &&operator+(Class const &lhs, Class const &rhs) { cout << "operator+(Class const &, Class const &)\n"; Class tmp(lhs); return operator+(std::move(tmp), rhs); } template <typename Class> Class &&operator-(Class &&lhs, Class const &rhs) { return std::move(lhs -= rhs); } template <typename Class> Class &&operator-(Class const &lhs, Class const &rhs) { Class tmp(lhs); return operator-(std::move(tmp), rhs); } class Class { public: Class() = default; Class(Class const &other) = default; Class(int) {} Class(Class &&tmp) { cout << "Move constructor\n"; } Class &operator=(Class const &rhs) = default; Class &operator+=(Class const &rhs) { cout << "operator+=\n"; return *this; } }; Class factory() { return Class{}; } int main() { Class lhs; Class rhs; Class result; result = lhs + rhs; result = factory() + rhs; // result = lhs - rhs; // 이것은 컴파일되지 않는다. // operator-=가 아직 정의되지 않았기 때문이다. }
result = rhs + 2;컴파일러 에러를 일으킨다. 템플릿 인자 추론 알고리즘이 승격을 인지하지 못하기 때문이다. 위의 서술문을 컴파일러가 받아들이도록 재작성할 필요가 있다.
result = rhs + Class(2);
승격이 바람직하다면 어떻게 산술 연산자 함수 템플릿을 바꾸어서 승격하도록 만들 수 있을까? 승격이라면 연산자 함수의 인자는 유형에 상관이 없다. 적어도 그 중에 하나는 적절한 반영 할당 연산자를 제공하는 클래스 유형이어야 한다. 그러나 함수 템플릿을 설계할 때 두 피연산자 중 어느 연산자가 클래스 유형인지 알 수 없다. 그래서 연산자 함수의 두 매개변수에 두 개의 템플릿 유형의 매개변수를 지정할 필요가 있다. 그러므로 함수 템플릿은 다음과 같이 시작해야 한다.
template <typename LHS, typename RHS> ReturnType operator+(LHS const &lhs, RHS const &rhs)이 시점에서 아직은
ReturnType
을 지정할 수 없다. RHS
를 승격할 수 있거나 LHS
와 같다면 LHS
가 될 것이고 LHS
가 RHS
로 승격된다면 RHS
가 될 것이기 때문이다.
RHS
를 LHS
로 승격할 수 있는지 알아 보기 위해 이제 간단하게 LpromotesR
템플릿 메타 프로그래밍 클래스를 설계한다. 이 템플릿은 두 개의 템플릿 유형 매개변수를 사용한다. 두 번째 (오른쪽) 유형을 첫 번째 (왼쪽) 유형으로 승격할 수 있으면 값을 true
(1)로 정의하고 그렇지 않으면 값을 false
(0)로 정의한다. 이전 유형 변환의 가능성에서 보았던 규칙을 적용한다 (23.8.3항).
template <typename L, typename R> class LpromotesR { struct Char2 { char array[2]; }; static R const &makeR(); static char test(L const &); static Char2 test(...); public: LpromotesR() = delete; enum { yes = sizeof(test(makeR())) == sizeof(char) }; };
LpromotesR
클래스에서 R
을 L
로 승격할 수 있으면 test(L const &)
함수가 선택되고 그렇지 않으면 test(...)
가 선택된다. 이 두 test
함수의 반환 유형은 크기가 다르기 때문에 컴파일러는 enum
값 yes
에 1 또는 0을 할당할 수 있다. 생성자에 R
유형이 explicit
키워드로 명시적으로 선언되어 있으면 yes
의 값은 0이다. 그리고 L
과 R
이 어쩌다가 유형이 같으면 1이다.
이제 유형을 다른 유형으로 승격할 수 있는지 없는지 결정할 수 있으므로 LHS
또는 RHS
를 함수 템플릿의 반환 유형으로 선택할 수 있다. RHS
를 LHS
로 승격할 수 있으면 LHS
를 함수 템플릿의 반환 유형으로 사용하라. 그렇지 않으면 RHS를 사용하라.
물론 세 번째 가능성이 있다. LHS
와 RHS
유형은 서로 상대방의 생성자가 사용할 수 없다. 그 경우, 또다른 생성자가 어디엔가 자리하고 있어서 그 상황을 처리해 주지 않는 한, 컴파일러는 다음과 같은 에러 메시지를 보여준다.
no match for 'operator+' in '...'
승격 가능한 유형으로 되돌아가자. 이제 LpromotesR
을 사용하면 어느 유형을 승격할 수 있는지 결정할 수 있다. 돌려주는 값을 이전에 소개한 IfElse
메타 프로그래밍 클래스 템플릿에서 선택자로 사용할 수 있다 (23.2.2.2목).
이제 LHS
또는 RHS
를 연산자 템플릿 함수의 반환 유형으로 선택할 수 있으므로 승격을 지원하는 산술 연산자 함수 템플릿을 생성할 수 있다.
template <typename LHS, typename RHS> typename FBB::IfElse<FBB::LpromotesR<LHS, RHS>::yes, LHS, RHS>::type operator<<(LHS const &lhs, RHS const &rhs) { typedef typename FBB::IfElse< FBB::LpromotesR< LHS, RHS >::yes, LHS, RHS >::type Type; Type tmp(lhs); return std::move(tmp) << type(rhs); }함수의 반환 유형은
IfElse
의 type
이다. RHS
를 LHS
로 승격할 수 있으면 LHS
로 선택되고 아니면 RHS
가 선택된다. 같은 트릭이 함수 몸체에서 사용되어 tmp
의 유형을 결정한다.
이제 승격을 할 수 있다. rvalue 참조 매개변수를 정의한 함수 템플릿은 그대로이다. 이 모든 것을 이용하여 컴파일러는 다음 결정을 내릴 수 있다 (Class
를 의도된 클래스 이름으로 사용하고 Type
을 Class
로 승격할 수 있는 유형처럼 사용하며 그리고 @
를 사용된 연산자의 총칭 표식으로 사용한다.). 그렇지 않고 따로 지정하지 않으면 (LHS const &lhs, RHS const &rhs)
매개변수 리스트를 정의한 함수 템플릿이 사용된다.
Class obj; Type value; obj @ obj // 승격 없음 obj @ Class() // 위와 같음 obj @ value; // value는 Class로 승격됨 Class() @ value; // 위와 같음 value @ obj; // 위와 같음 value @ Class(); // 위와 같음 Class() @ obj; // operator@(Class &&, Class const &)를 호출함 Class() @ Class(); // 위와 같음
const &
인자를 기대하는 이항 연산자는 그 연산을 구현한 멤버를 사용하여 구현할 수 있다. 기본 예외만 보장한다. 이 번에는 이항 할당 멤버를 사용하여 구현할 수 있다. 다음 예제는 Binary
클래스에 대하여 이 접근법을 보여준다.
class Binary { public: Binary(); Binary(int value); // 기본으로 복사 생성자와 이동 생성자가 있다. 아니면, // 명시적으로 선언하고 구현하면 된다. Binary &operator+=(Binary const &other) &; // 본문 참조 Binary &&operator+=(Binary const &other) &&; private: void add(Binary const &rhs); friend Binary operator+(Binary const &lhs, Binary const &rhs); friend Binary operator+(Binary &&lhs, Binary const &rhs); };
결국 이항 연산의 구현은 기본 이항 연산을 구현하고 자신을 호출한 객체를 변경하는 멤버가 (즉, 예제에서는 void Binary::add(Binary const &)
멤버가) 있는가 없는가에 달려 있다.
템플릿 함수는 실제로 사용되기 전에는 구체화되지 않으므로 절대로 구체화되지 않는 템플릿 함수로부터 존재하지 않는 함수를 호출할 수 있다. 그런 템플릿 함수가 절대로 구체화되지 않는다면, 아무 일도 일어나지 않는다. (우발적으로) 구체화되더라도 컴파일러가 에러 메시지를 보여주고 함수가 없다고 불평할 것이다.
이렇게 하면 이동 가능하든 불가능하든 모든 이항 연산자를 템플릿으로 구현할 수 있다. 다음 항에서 이항 연산자를 제공하는 Binops
클래스 템플릿을 개발한다. Derived
클래스의 완벽한 구현은 C++ 주해서의 소스 저장소 annotations/yo/concrete/examples/binopclasses.cc
파일에 있다. 삽입 연산자와 추출 연산자를 어떻게 클래스에 추가하는지 보여준다.
add
멤버의 관점에서 구현되었다. 이 방법은 함수 템플릿을 개발할 때는 별로 매력적이지 않다. add
함수는 비밀 멤버이고 모든 함수 템플릿에 친구로 선언해야만 그 함수에 접근할 수 있기 때문이다.
11.6절의 말미에서 add
의 구현이 operator+=(Class const &rhs) &&
으로 제공되는 것을 보았다. 이 연산자는 그 시점부터 나머지 덧셈 연산자를 구현한다.
inline Binary &operator+=(Binary const &rhs) & { return *this = Binary{*this} += rhs; } Binary operator+(Binary &&lhs, Binary const &rhs) { return std::move(lhs) += rhs; } Binary operator+(Binary const &lhs, Binary const &rhs) { return Binary{lhs} += rhs; }
이 구현에서 add
멤버는 더 이상 필요하지 않다. 평범한 이항 연산자는 자유 함수이다. 그러므로 손쉽게 함수 템플릿으로 변환할 수 있을 것이다. 예를 들어,
template <typename Binary> Binary operator+(Binary const &lhs, Binary const &rhs) { return Binary{lhs} += rhs; }
Binary operator+(Binary const &lhs, Binary const &rhs)
를 사용할 때 예상하지 못한 미묘한 문제를 만날 가능성이 있다. 다음 프로그램을 연구해 보자. 실행하면 1이 아니라 12를 화면에 출력한다.
enum Values { ZERO, ONE }; template <typename Tp> Tp operator+(Tp const &lhs, Tp const &rhs) { return static_cast<Tp>(12); }; int main() { cout << (ZERO + ONE); // 12를 보여준다. }이 복잡성을 피하려면 연산자를 각자의 이름공간에 정의하면 된다. 그러나 그러면 그 이항 연산자를 사용하는 모든 클래스도 그 이름공간에 정의할 필요가 있다. 이런 제한은 별로 달갑지 않다. 다행스럽게도 더 좋은 대안이 있다. CRTP를 사용하면 된다 (22.12절).
Binops
클래스 템플릿으로부터 클래스를 파생시킬 때 CRTP(Curiously Recurring Template Pattern)를 사용하여 Binops<Derived>
클래스의 인자들에 대하여 연산자를 정의한다. 파생 클래스를 자신의 템플릿 인자로 받는 바탕 클래스를 정의한다.
그리하여 Binops
클래스와 더불어 Binops<Derived>
유형의 인자를 기대하는 연산자들도 추가로 함께 정의한다.
template <class Derived> struct Binops { Derived &operator+=(Derived const &rhs) &; }; template <typename Derived> Derived operator+(Binops<Derived> const &lhs, Derived const &rhs) { return Derived{static_cast<Derived> const &>(lhs) } += rhs; } // Binops<Derived> &&lhs도 비슷하게 구현
이런 식으로 클래스가 Binops
로부터 파생되고 rvalue 참조 객체에 묶인 operator+=
멤버를 지원하면 갑자기 다른 모든 이항 덧셈 연산자들이 제공된다.
class Derived: public Binops<Derived> { ... public: ... Derived &&operator+=(Derived const &rhs) && };거의 모두 지원되지만, 하나만은 예외인데....
사용이 불가능한 연산자는 바로 lvalue 참조에 묶인 반영 덧셈 연산자이다. 그의 함수 이름이 Derived
클래스에 있는 함수 이름과 동일하기 때문에 사용자 수준에서는 보이지 않는다.
Derived
클래스에 using Binops<Derived>::operator+=
를 선언하면 이 문제를 쉽게 해결할 수 있지만 별로 마음에 드는 해결책은 아니다. Derived
클래스에 구현된 이항 연산자마다 따로따로 using 선언을 제공할 필요가 있기 때문이다.
그러나 훨씬 더 바람직한 해결책이 있다. 숨겨진 바탕 클래스 연산자를 완벽하게 피하는 지극히 아름다운 방법이 비베-마틴 윈지아(Wiebe-Marten Wijnja)에 의하여 제안되었다. 그의 추측에 의하면 operator+=
를 lvalue 참조에 묶는다면 자유 함수로 문제없이 잘 정의할 수 있을 것이다. 그 경우 상속이 사용되지 않는다. 그러므로 함수가 보이지 않는 일은 일어나지 않는다. 결론적으로 using
지시어를 사용하지 않아도 된다.
다음과 같이 operator+=
자유 함수를 구현한다.
template <class Derived> Derived &operator+=(Binops<Derived> &lhs, Derived const &rhs) { Derived tmp{ Derived{ static_cast<Derived &>(lhs) } += rhs }; tmp.swap(static_cast<Derived &>(lhs)); return static_cast<Derived &>(lhs); }
오른쪽 피연산자가 꼭 Derived
클래스의 실체일 필요는 없다는 사실을 깨닫으면 이 디자인은 훨씬 더 유연해질 수 있다. operator<<
를 연구해 보자. 비트별로 쉬프트하는 경우가 많다. size_t
를 사용하여 쉬프트할 비트의 갯수를 지정한다. 실제로 두 번째 템플릿 유형 매개변수를 정의하면 오른쪽 피연산자의 유형을 완전히 일반화할 수 있다. 이 매개변수는 오른쪽 피연산자의 유형을 지정한다. operator+=
의 인자 유형을 지정하는 것은 Derived
클래스에 달려 있다 (또는 다른 모든 이항 반영 연산자). 그러면 컴파일러는 나머지 이항 연산자들에 대하여 오른쪽 피연산자들의 유형을 추론할 것이다. 다음은 operator+=
자유 함수의 최종 구현이다.
template <class Derived, typename Rhs> Derived &operator+=(Binops<Derived> &lhs, Rhs const &rhs) { Derived tmp{ Derived{ static_cast<Derived &>(lhs) } += rhs }; tmp.swap(static_cast<Derived &>(lhs)); return static_cast<Derived &>(lhs); }
void insert(std::ostream &out) const
로 객체를 ostream
에 삽입하고 void extract(std::istream &in) const
로 객체를 istream
으로부터 추출할 수 있다. 이 함수들은 각각 삽입 연산자와 추출 연산자에서만 사용되므로 Derived
클래스의 비밀 인터페이스에 선언할 수 있다. 삽입 연산자와 추출 연산자를 Derived
클래스의 친구로 선언하는 대신에 friend Binops<Derived>
만 지정한다. 이렇게 하면 Binops<Derived>
로 비밀 구역에 인라인으로 iWrap
멤버와 eWrap
멤버를 정의할 수 있다. 각각 Derived
의 insert
멤버와 extract
멤버를 호출하는 일만 한다.
template <typename Derived> inline void Binops<Derived>::iWrap(std::ostream &out) const { static_cast<Derived const &>(*this).insert(out); }그 다음
Binops<Derived>
는 삽입 연산자와 추출 연산자를 친구로 선언한다. 그러면 이 연산자들은 각각 iWrap
과 eWrap
을 호출할 수 있다. Derived
클래스를 설계하는 프로그래머는 friend Binops<Derived>
를 선언하기만 하면 된다. 다음은 중복정의 삽입 연산자의 구현이다.
template <typename Derived> std::ostream &operator<<(std::ostream &out, Binops<Derived> const &obj) { obj.iWrap(out); return out; }
이 정도면 Binops
클래스 템플릿의 핵심 기능을 총 망라한 셈이다. 잠재적으로 Binops
로부터 파생된 모든 클래스에 이항 연산자와 삽입/추출 연산자를 제공한다. 마지막으로 이 항의 서두에 지적했듯이 삽입 연산자와 추출 연산자를 제공하는 클래스를 완벽하게 구현한 것은 C++ 주해서의 소스 저장소에 있는 annotations/yo/concrete/examples/binopclasses.cc
파일에 있다.
begin
과 end
멤버를 통하여) 컨테이너가 제공하는 반복자 범위를 요구한다.
포인터 쌍으로 정의된 범위나 반복자 표현식으로 정의된 부범위는 현재 범위-기반의 for-회돌이와 조합하여 사용할 수 없다.
이 절에서 개발할 Ranger
클래스 템플릿은 범위-기반의 for-회돌이와 사용할 수 있는 범위를 정의한다. Ranger
는 범위-기반의 응용 범위를 확장한다. 포인터 쌍, 최초의 포인터나 반복자 그리고 포인터 갯수, 또는 한 쌍의 반복자를 범위-기반의 for-회돌이에서 사용할 수 있는 범위로 변환한다. Ranger
클래스 템플릿은 한 쌍의 역방향 반복자를 처리하는 데에도 사용할 수 있다. 이는 범위-기반의 for-회돌이에서 지정하지 못한다.
Ranger
클래스 템플릿은 템플릿 유형 매개변수를 하나만 요구한다. Iterator
가 그것으로서 역참조할 때 데이터에 도달하는 포인터 유형 또는 반복자를 나타낸다. 실제 어플리케이션에서는 Ranger
의 템플릿 유형을 지정할 필요가 없다. ranger
함수 템플릿은 필요한 Iterator
유형을 추론하여 적절한 Ranger
객체를 돌려준다.
ranger
함수 템플릿은 다양한 방식으로 사용할 수 있다.
Ranger<Iterator> ranger(Iterator const &begin, Iterator const &end)
이 함수 템플릿은 두 개의 (역방향) 반복자로 정의된 (부)범위를 담은 Ranger
객체를 돌려준다. 정의는 다음과 같다.
template <typename Iter> Ranger<Iter> ranger(Iter &&begin, Iter &&end) { return Ranger<Iter>(begin, end); }
Ranger<Iterator> ranger(Iterator const &begin, size_t count)
이 함수 템플릿은 (역방향) 반복자 범위 begin
과 begin + count
로 정의된 (부) 범위를 담은 Ranger
객체를 돌려준다. 정의는 다음과 같다.
template <typename Data> Ranger<Data *> ranger(Data *begin, Data *end) { return Ranger<Data *>(begin, end); }
Ranger<Data> ranger(Data *begin, Data *end)
이 함수는 두 개의 포인터 begin
과 end
로 정의된 (부) 범위를 담은 Ranger
객체를 돌려준다. 정의는 다음과 같다.
template <typename Iter> Ranger<Iter> ranger(Iter &&begin, size_t count) { return Ranger<Iter>(begin, begin + count); }
Ranger<Data> ranger(Data *begin, size_t count)
이 함수 템플릿은 두 개의 포인터 begin
과 begin + count
으로 정의된 (부) 범위를 담은 Ranger
객체를 돌려준다. 그 정의는 다음과 같다.
template <typename Data> Ranger<Data *> ranger(Data *begin, size_t count) { return Ranger<Data *>(begin, begin + count); }
Ranger
클래스 템플릿 자체는 두 개의 Iterator const &
매개변수를 기대하는 생성자를 제공한다. 여기에서 Iterator
는 Ranger
의 템플릿 유형 매개변수이다. 이름붙은 'Iterator'이지만 어떤 데이터 유형을 가리키는 포인터일 수도 있다 (예를 들어 std::string *
).
이 클래스는 begin
과 end
두 개의 멤버만 요구한다. 범위-기반의 for-회돌이에서 호출하는 유일한 멤버들이기 때문이다. 이 멤버들은 Iterator const
참조를 돌려주는 const
멤버일 수 있다. Iterator
자체가 포인터 유형이라면 (int *
와 같이) 이것이 필수적인 반환 유형이다. `Iterator const &
'라고 해서 역참조된 Iterator
가 변경 불가능하다는 뜻은 아니기 때문에 begin()
이 반환한 반복자가 가리키는 데이터가 실제로는 변경이 될 가능성이 있다. Iterator
라고 할지라도 Iterator
가 Type const *
이 아니거나 const_iterator
유형이 아니라면 변경될 수 있다.
역방향 반복자를 Ranger
의 생성자에 건네면 (역방향 시작 반복자는 Ranger
생성자의 첫 인자로 건네고 역방향 끝 반복자는 두 번째 인자로 건네야 한다) Ranger
의 begin
과 end
멤버는 역방향 반복자를 돌려준다. Ranger
객체를 사용하는 의도는 범위-기반의 for-회돌이에 대하여 범위를 정의하는 것이기 때문에 rbegin
과 rend
같은 멤버는 Ranger
의 인터페이스에서 빼버렸다.
다음은 Ranger
의 (인-클래스) 구현이다.
template <typename Iter> class Ranger { Iter d_begin; Iter d_end; public: Ranger(Iter const &begin, Iter const &end) : d_begin(begin), d_end(end) {} Iter const &begin() const { return d_begin; } Iter const &end() const { return d_end; } };
ranger
를 사용하는 것은 쉽다. 다음은 범위 기반의 for-회돌이로 프로그램의 명령줄 인자를 보여주는 예이다.
// 필요한 모든 선언은 여기에 삽입한다. int main(int argc, char **argv) { for (auto ptr: ranger(argv, argc)) cout << ptr << '\n'; }
operator[]
의 문제는 lvalue로 사용되는지 rvalue로 사용되는지 구분하지 못한다는 것이다. 그래서 다음과 같은 오해에 익숙하다.
Type const &operator[](size_t index) const이것은 (객체가 변경되지 않기 때문에) rvalue로 사용된다고 생각한다.
Type &operator[](size_t index)이것은 (반환된 값을 변경할 수 있기 때문에) lvalue로 사용된다고 생각한다.
그러나 컴파일러는 operator[]
가 호출된 객체의 const
-상태로만 두 연산자 사이를 구분한다. const
객체라면 앞의 연산자가 호출되고 비-const
객체라면 언제나 뒤의 연산자가 사용된다. lvalue로 사용되든 rvalue로 사용되든 그에 상관이 없다.
lvalue와 rvalue 사이를 구분할 수 있으면 아주 유용하다. operator[]
를 지원하는 클래스가 아주 복사하기 어려운 유형의 데이터를 저장하고 있는 상황을 생각해 보자. 그런 데이터라면 불필요한 복사를 방지하기 위하여 아마도 (예를 들어 shared_ptr
을 사용한) 참조 횟수 세기가 사용될 것이다.
operator[]
이 rvalue로 사용되는 한 그 데이터를 복사할 필요가 없다. 그러나 lvalue로 사용된다면 반드시 그 정보를 복사해야 한다.
프록시 디자인 패턴을 (cf. Gamma et al. (1995)) 사용하면 lvalue와 rvalue 사이를 구별할 수 있다. 프록시 디자인 패턴에서는 `실제 대상'을 대신하여 행위하기 위하여 또다른 클래스의 객체가 사용된다. 프록시(Proxy) 클래스는 데이터 자체가 제공할 수 없는 편의기능을 제공한다. 예를 들어 lvalue로 사용되는지 rvalue로 사용되는지 구별할 수 있다. 프록시 클래스는 실제 데이터에 직접적으로 접근할 수 없거나 또는 그러면 안되는 상황에 사용할 수 있다. 이런 관점에서 반복자 유형은 프록시 클래스의 예이다. 실제 데이터와 그 데이터를 사용하는 소프트웨어 사이에 한 겹의 층이 되어 주기 때문이다. 프록시 클래스는 포인터로 데이터를 저장한 클래스에서 포인터를 역참조할 수도 있다.
이 절은 operator[]
가 lvalue로 사용되는지 아니면 rvalue로 사용되는지 구별하는 데 집중한다. 파일로부터 줄을 읽어 저장하는 Lines
라는 클래스가 있다고 가정하자. 그의 생성자는 줄을 읽어 들일 스트림의 이름을 기대한다. 그리고 lvalue 또는 rvalue로 사용할 수 있는 비-상수 operator[]
를 제공한다 (const
버전의 operator[]
는 생략한다. 언제나 rvalue이므로 구별하지 않아도 되기 때문이다).
class Lines { std::vector<std::string> d_line; public: Lines(std::istream &in); std::string &operator[](size_t idx); };
lvalue와 rvalue 사이를 구별하기 위하여 그 사이에 이용할 수 있는 특징을 발견해야 한다. 그런 구분 특징은 (언제나 lvalue로 사용되는) operator=
연산자 그리고 (언제나 rvalue로 사용되는) 변환 연산자이다. operator[]
에게 string &
을 돌려주도록 하는 대신에 Proxy
객체를 돌려주도록 만들 수 있다. 이 객체는 lvalue로 사용되는지 rvalue로 사용되는지 구별할 수 있다.
그래서 Proxy
클래스는 (lvalue로 행위하는) operator=(string const &other)
와 그리고 (rvalue로 행위하는) operator std::string const &() const
가 필요하다. 연산자가 더 필요할까? std::string
클래스는 operator+=
로 제공한다. 그래서 이 연산자도 구현해야 할 듯 싶다. 평범한 문자들도 string
객체에 할당할 수 있다 (심지어 숫치 값을 할당해도 된다). string
객체는 평범한 문자로부터 생성할 수 없기 때문에 오른쪽 인자가 문자라면 operator=(string const &other)
와 함께승격을 사용할 수 없다. 그러므로 operator=(char value)
를 구현하는 것도 고려할 수 있다. 이 추가 연산자들은 현재 구현에서 빠졌지만 `실 세계의' 프록시 클래스라면 구현하는 것을 고려해야 한다.
또다른 미묘한 점은 ostream
의 삽입 연산자나 istream
의 추출 연산자를 사용할 때 Proxy
의 operator std::string const &() const
가 사용되지 않는다는 것이다. 이 연산자들은 우리의 Proxy
클래스 유형을 인지하지 못하는 템플릿으로 구현되어 있기 때문이다. 그래서 스트림 삽입과 추출이 요구되면 (아마도 그럴 것이다) Proxy
에 따로 중복정의 삽입 연산자와 추출 연산자를 구현해야 한다. 다음은 중복정의 삽입 연산자 구현이다. 대신할 객체를 Proxy
가 삽입한다.
inline std::ostream &operator<<(std::ostream &out, Lines::Proxy const &proxy) { return out << static_cast<std::string const &>(proxy); }
(Lines
를 제외하고) Proxy
객체를 복사하거나 생성하는 데 코드가 전혀 필요없다. 그러므로 Proxy
의 생성자는 비밀로 만들어야 하고 Proxy
는 Lines
를 친구로 선언할 수 있다. 사실, Proxy
는 Lines
에 밀접하게 관련되어 있으며 내포 클래스로 정의할 수 있다. 개선된 Lines
클래스에서 operator[]
연산자는 더 이상 string
을 돌려주지 않는다. 대신에 Proxy
를 돌려준다. 다음은 개선된 Lines
클래스로서 Proxy
클래스를 안에 내포한다.
class Lines { std::vector<std::string> d_line; public: class Proxy; Proxy operator[](size_t idx); class Proxy { friend Proxy Lines::operator[](size_t idx); std::string &d_str; Proxy(std::string &str); public: std::string &operator=(std::string const &rhs); operator std::string const &() const; }; Lines(std::istream &in); };
Proxy
의 멤버들은 아주 가볍다. 그리고 인라인으로 구현할 수 있다.
inline Lines::Proxy::Proxy(std::string &str) : d_str(str) {} inline std::string &Lines::Proxy::operator=(std::string const &rhs) { return d_str = rhs; } inline Lines::Proxy::operator std::string const &() const { return d_str; }
Lines::operator[]
멤버도 인라인으로 구현할 수 있다. 그저 인덱스 idx
와 연관된 string
으로 초기화하여 Proxy
객체를 돌려주기만 하면 된다.
이제 Proxy
클래스를 개발했으므로 프로그램에 사용할 수 있다. 다음은 Proxy
객체를 lvalue 또는 rvalue로 사용하는 예제이다. 겉보기에 Lines
객체는 원래의 구현을 사용하는 Lines
객체와 별다르게 행위하지 않는다. 그러나 Proxy
의 멤버에 식별하는 cout
서술문을 추가해 보면 operator[]
연산자가 lvalue로 사용될 때와 rvalue로 사용될 때 서로 다르게 행위하는 것을 볼 수 있다.
int main() { ifstream in("lines.cc"); Lines lines(in); string s = lines[0]; // rvalue 사용 lines[0] = s; // lvalue 사용 cout << lines[0] << '\n'; // rvalue 사용 lines[0] = "hello world"; // lvalue 사용 cout << lines[0] << '\n'; // rvalue 사용 }
이렇게 내포된 반복자 클래스의 객체는 벡터 안에 저장된 포인터의 역참조를 처리한다. 이 덕분에 포인터가 아니라 벡터의 원소가 가리키는 문자열을 정렬할 수 있다.
이 방법의 단점은 반복자를 구현한 클래스가 파생 클래스와 긴밀하게 묶인다는 것이다. 반복자 클래스가 내포 클래스로 구현되었기 때문이다. 포인터를 저장한 컨테이너 클래스로부터 파생된 클래스에 포인터-역참조를 처리하는 반복자를 제공하고 싶다면 어떻게 할 것인가?
이 절은 이전의 (내포 클래스) 접근법을 다양하게 논의한다. 여기에서 반복자 클래스는 클래스 템플릿으로 정의된다. 컨테이너의 요소들이 가리키는 데이터 유형은 물론이고 컨테이너 반복자 유형 자체가 가리키는 데이터 유형을 매개변수화한다. 다시 한 번 RandomIterator의 개발에 집중하자. 가장 복잡한 반복자 유형이기 때문이다.
클래스는 이름이 RandomPtrIterator
인데, 이름 그대로 포인터 값에 작동하는 무작위 반복자이다. 클래스 템플릿에 템플릿 유형 매개변수가 세 가지 정의된다.
첫 매개변수는 파생 클래스 유형을 지정한다 (Class
). 이전과 같이 RandomPtrIterator
의 생성자는 비밀이다. 그러므로 클라이언트 클래스에게 RandomPtrIterators
의 생성을 허용하려면 friend
선언이 필요하다. 그렇지만 friend class Class
는 사용할 수 없다. 템플릿 매개변수 유형을 friend class ...
선언에 사용할 수 없기 때문이다. 그러나 이것은 사소한 문제이다. 클라이언트 클래스의 모든 멤버가 반복자를 생성할 필요가 있는 것은 아니기 때문이다. 실제로 오직 Class
의 begin
과 end
멤버만 반복자를 생성하면 된다. 템플릿의 첫 매개변수를 사용하여 클라이언트의 begin
과 end
멤버에 친구로 선언할 수 있다.
두 번째 템플릿 매개변수는 컨테이너의 반복자 유형을 매개변수화한다 (BaseIterator
);
세 번째 템플릿 매개변수는 포인터가 가리키는 데이터 유형을 나타낸다 (Type
).
RandomPtrIterator
는 비공개 BaseIterator
데이터 멤버가 하나 있다. 다음은 클래스 인터페이스와 생성자 구현이다.
#include <iterator> template <typename Class, typename BaseIterator, typename Type> class RandomPtrIterator: public std::iterator<std::random_access_iterator_tag, Type> { friend RandomPtrIterator<Class, BaseIterator, Type> Class::begin(); friend RandomPtrIterator<Class, BaseIterator, Type> Class::end(); BaseIterator d_current; RandomPtrIterator(BaseIterator const ¤t); public: bool operator!=(RandomPtrIterator const &other) const; int operator-(RandomPtrIterator const &rhs) const; RandomPtrIterator operator+(int step) const; Type &operator*() const; bool operator<(RandomPtrIterator const &other) const; RandomPtrIterator &operator--(); RandomPtrIterator operator--(int); RandomPtrIterator &operator++(); RandomPtrIterator operator++(int); bool operator==(RandomPtrIterator const &other) const; RandomPtrIterator operator-(int step) const; RandomPtrIterator &operator-=(int step); RandomPtrIterator &operator+=(int step); Type *operator->() const; }; template <typename Class, typename BaseIterator, typename Type> RandomPtrIterator<Class, BaseIterator, Type>::RandomPtrIterator( BaseIterator const ¤t) : d_current(current) {}
friend
선언을 보면 Class, BaseIterator, Type
유형에 대하여 RandomPtrIterator
객체를 돌려주는 Class
클래스의 begin
멤버와 end
멤버가 RandomPtrIterator
의 비공개 생성자에 접근하는 것을 허용한다. 정확하게 우리가 원하는 것이다. Class
의 begin
멤버와 end
멤버는 묶인 친구로 선언된다.
RandomPtrIterator
의 나머지 모든 멤버는 공개이다. RandomPtrIterator
는 22.14.1항에서 개발한 iterator
내포 클래스를 일반화한 것일 뿐이기 때문에 필요한 멤버 함수를 재구현하는 것은 어렵지 않다. 그냥 iterator
를 RandomPtrIterator
로 바꾸고 std::string
을 Type
으로 바꾸기만 하면 된다. 예를 들어 iterator
클래스에 다음과 같이 정의된 operator<
는
inline bool StringPtr::iterator::operator<(iterator const &other) const { return d_current < other.d_current; }
이제 다음과 같이 구현된다.
template <typename Class, typename BaseIterator, typename Type> bool RandomPtrIterator<Class, BaseIterator, Type>::operator<( RandomPtrIterator const &other) const { return **d_current < **other.d_current; }
다음에 예를 몇 가지 더 보여준다. iterator
클래스에 다음과 같이 정의된 operator*
는 이제
inline std::string &StringPtr::iterator::operator*() const { return **d_current; }
다음과 같이 구현된다.
template <typename Class, typename BaseIterator, typename Type> Type &RandomPtrIterator<Class, BaseIterator, Type>::operator*() const { return **d_current; }
전위 증가 연산자와 후위 증가 연산자는 이제 다음과 같이 구현된다.
template <typename Class, typename BaseIterator, typename Type> RandomPtrIterator<Class, BaseIterator, Type> &RandomPtrIterator<Class, BaseIterator, Type>::operator++() { ++d_current; return *this; } template <typename Class, typename BaseIterator, typename Type> RandomPtrIterator<Class, BaseIterator, Type> RandomPtrIterator<Class, BaseIterator, Type>::operator++(int) { return RandomPtrIterator(d_current++); }
나머지 멤버는 그에 맞게 구현할 수 있다. 실제 구현은 독자 여러분에게 연습 문제로 남긴다 (아니면, 물론 cplusplus.yo.zip
압축파일에서 얻을 수 있다).
22.14.1항에서 개발한 StringPtr
클래스를 재구현하는 것도 역시 어렵지 않다. RandomPtrIterator
클래스 템플릿을 정의한 헤더를 포함하는 것을 빼면 한 곳만 변경하면 된다. iterator
에 대하여 typedef
는 이제 RandomPtrIterator
와 연관지어야 한다. 다음은 완전한 클래스 인터페이스와 클래스의 인라인 멤버 정의들이다.
#ifndef INCLUDED_STRINGPTR_H_ #define INCLUDED_STRINGPTR_H_ #include <vector> #include <string> #include "iterator.h" class StringPtr: public std::vector<std::string *> { public: typedef RandomPtrIterator < StringPtr, std::vector<std::string *>::iterator, std::string > iterator; typedef std::reverse_iterator<iterator> reverse_iterator; iterator begin(); iterator end(); reverse_iterator rbegin(); reverse_iterator rend(); }; inline StringPtr::iterator StringPtr::begin() { return iterator(this->std::vector<std::string *>::begin() ); } inline StringPtr::iterator StringPtr::end() { return iterator(this->std::vector<std::string *>::end()); } inline StringPtr::reverse_iterator StringPtr::rbegin() { return reverse_iterator(end()); } inline StringPtr::reverse_iterator StringPtr::rend() { return reverse_iterator(begin()); } #endif
StringPtr
의 변경된 헤더 파일을 22.14.2항에 주어진 프로그램에 포함하기만 하면 그 결과 프로그램은 이전 버전과 동일하게 행위한다. 이 경우 StringPtr::begin
과 StringPtr::end
는 템플릿 정의로부터 생성된 반복자 객체를 돌려준다.
이 절과 다음 절의 예제는 여러분이 스캐너 생성기 flex
와 파서 생성기 bison
를 사용하는 법을 알고 있다고 가정한다. 두 생성기 bison
과 flex
모두 다른 곳에 문서화가 잘 되어 있다. bison
과 flex
의 원조인 yacc
와 lex
는 여러 책에 잘 기술되어 있다. 예를 들어 오라일리(O'Reilly)사의 책 `lex & yacc'를 참고하라.
스캐너 생성기와 파서 생성기는 무료 소프트웨어이다. bison
과 flex
둘 다 배포본에 표준으로 포함된다. 아니면 ftp://prep.ai.mit.edu/pub/non-gnu으로부터 얻을 수 있다. %option c++
옵션을 지정하면 Flex
는 C++
클래스를 생성한다.
파서 생성기라면 bison
프로그램을 사용할 수 있다. 90년대 초, 알레인 꼬에모(Alain Coetmeur)는 (coetmeur@icdc.fr) 파서 클래스를 생성하는 (bison++
) C++ 변형을 만들었다. bison++
프로그램이 만든 코드는 C++ 프로그램에 사용할 수 있지만 C++ 문맥보다 C 문맥에 더 적합한 특징들도 많이 보여준다. 2005년 1월 필자는 알레인(Alain)의 bison++
프로그램 일부를 재작성했다. 그 결과로 최초의 bisonc++ 버전이 탄생했다. 그 후 2005년 5월 완전히 재작성된 bisonc++
파서 생성기가 완성되었다 (버전 번호 0.98). bisonc++
의 현재 버전은 https://fbb-git.github.io/bisoncpp/으로부터 내려받을 수 있다. (bisonc++
의 문서를 비롯하여) 소스와 그리고 이진 (i386) 데비안 패키지를 얻을 수 있다.
Bisonc++
는 bison++
보다 파서 클래스를 더 깔끔하게 만들어 낸다. 특히, 파서 클래스를 바탕 클래스로부터 상속받는다. 안에 파서의 토큰 정의와 유형 정의를 비롯하여 프로그래머가 (재)정의하면 안되는 멤버들이 모두 들어 있다. 이 접근법의 결과로 아주 작은 파서 클래스가 생성된다. 프로그래머가 실제로 정의한 멤버들만 선언한다 (bisonc++
자체에서 생성하는 어떤 멤버는 파서의 parse()
멤버를 구현한다). 기본으로 구현되지 않는 멤버 하나는 다음 어휘 토큰을 생산하는 lex
이다. %scanner
(24.8.2.1목) 지시어를 사용할 때 bisonc++
는 이 멤버를 표준으로 구현한다. 그렇지 않으면 프로그래머가 구현해야 한다.
2012년 초에 flexc++ https://fbb-git.github.io/flexcpp/ 프로그램은 최초 배포 단계에 이르렀다. bisonc++
처럼 데비안 리눅스 배포본에 포함되어 있다.
장-폴 반 우스텐(Jean-Paul van Oosten (j.p.van.oosten@rug.nl))과 리차드 베렌센(Richard Berendsen (richardberendsen@xs4all.nl))은 flexc++
프로젝트를 2008년에 시작했다. 그리고 최종 프로그램이 장-폴(Jean-Paul)과 필자에 의하여 2010년과 2012년 사이에 완성되었다.
이 절은 파서 생성기로서 bisonc++
에 그리고 어휘 스캐너 생성기로서 flexc++
초점을 둔다. 이 책의 이전 배포본에서는 flex
를 스캐너 생성기로 사용했었다.
flex++
와 bisonc++
를 사용하여 class
-기반의 스캐너와 파서를 생성한다. 이 접근법의 장점은 class
인터페이스를 사용하지 않을 때보다 스캐너와 파서에 대한 인터페이스가 더 깔끔해진다는 것이다. 게다가 클래스 덕분에 전역 변수를 대부분 제거할 수 있으므로 한 프로그램에 여러 파서를 쉽게 사용할 수 있게 된다.
아래에 예제 프로그램을 두 가지 개발한다. 첫 예제는 flexc++
만 사용한다. 생성된 스캐너는 여러 부분으로부터 파일을 생산하는 것을 관제한다. 이 예제는 정보를 휘젓고 돌아다니는 동안 어휘 스캐너와 파일 사이의 전환에 초점이 있다. 두 번째 예제는 flexc++
와 bisonc++
를 모두 사용하여 스캐너와 파서를 생성한다. 표준 산술 표현식을 후위 표현식으로 변환한다. 후위 표현식은 컴파일러가 생산하는 코드와 HP
-계산기에 주로 사용된다. 두 번째 예제는 주로 bisonc++
에 초점이 있고 생성된 파서 안에서 스캐너 객체를 조합하는 것에 중점이 있다.
#include
지시어 다음에 이 파일을 포함해야 한다.
우리의 예제는 쓸데없는 복잡성을 피하기 위해 #include
서술문의 형식을 #include <filepath>
으로 제한한다. 옆꺽쇠 사이에 지정된 파일은 filepath
에 지시된 위치에서 사용할 수 있어야 한다. 그 파일을 사용할 수 없으면 프로그램은 에러 메시지를 보여주며 종료한다.
프로그램은 한 두 개의 파일이름을 인자로 하여 시작한다. 프로그램이 단 하나의 파일 인자로 시작하면 출력은 표준 출력 스트림 cout
에 씌여진다. 그렇지 않으면 출력은 프로그램의 두 번째 인자에 주어진 이름의 스트림에 씌여진다.
프로그램은 최대 내포 깊이를 정의한다. 이 최대 깊이를 초과하면 프로그램은 에러 메시지를 보여주고 종료된다. 그 경우, 파일이 포함된 위치를 알려주는 파일이름 스택이 출력된다.
프로그램의 추가 특징은 (표준 C++) 주석줄을 무시한다는 것이다. 명령줄 안의 include-지시어들도 역시 무시된다.
프로그램은 다섯 단계로 생성된다.
lexer
파일을 생성한다. 안에 입력-언어 규격이 들어 있다.
lexer
안의 규격으로부터 Scanner
클래스에 대한 요구조건을 도출한다. Scanner
클래스는 바탕 클래스 flexc++
이 생성한 ScannerBase
로부터 파생된다.
main
을 생성한다. 명령줄 인자를 조사하여 Scanner
객체를 생성한다. 성공하면 스캐너의 lex
멤버를 호출해 프로그램의 출력을 생산한다.
lex
함수는 Scanner
클래스의 멤버이다. Scanner
클래스는 ScannerBase
클래스로부터 파생되었으므로 어휘 스캐너의 정규 표현식 부합 알고리즘을 실행하는 ScannerBase
클래스의 모든 보호 멤버에 접근할 수 있다.
정규 표현식 자체를 보면 주석과 #include
지시어 그리고 나머지 모든 문자들을 인지하는 규칙이 필요함을 알 수 있다. 이 모든 것은 상당히 표준적인 관례이다. #include
지시어를 감지하면 그 지시어는 스캐너가 해석한다. 이 또한 보통의 관례이다. 우리의 어휘 스캐너는 다음과 같은 일을 한다.
flex
에 사용된 규격 파일과 비슷하게 조직된다. 그렇지만 C++ 문맥에서 flexc++
는 단순히 스캐너 함수가 아니라 Scanner
클래스를 생성한다.
Flexc++의 규격 파일은 두 부분으로 구성된다.
flexc++
의 심볼 구역이다. 미니 스캐너 또는 옵션처럼 심볼을 정의한다. 다음 옵션을 제공한다.
%debug
: 디버깅 코드를 flexc++
가 생산한 코드에 포함한다. setDebug(true)
멤버 함수를 호출하면 이 디버깅 코드가 실행 시간에 활성화된다. 활성화되면 부합하는 프로세스에 관한 정보가 표준 출력 스트림에 씌여진다. setDebug(false)
멤버 함수를 호출하면 디버그 코드의 실행이 억제된다.
%filenames
: flexc++
가 생산한 클래스 헤더 파일의 바탕-이름을 정의한다. 기본으로 클래스 이름이 (Scanner
) 사용된다.
%filenames scanner %debug %max-depth 3 %x comment %x include
std::cin
)으로부터 표준 출력 스트림(std::cout
)으로 복사해야 한다. 이를 위해 미리 정의된 매크로 ECHO
를 사용할 수 있다. 다음은 그 규칙이다.
%% // 주석-규칙: 주석은 무시된다. //.* // 줄끝 주석도 무시한다. "/*" begin(StartCondition__::comment); <comment>{ .|\n // 표준 C 주석 안의 모든 문자를 무시한다. "*/" begin(StartCondition__::INITIAL); } // 파일 전환: #include <filepath> #include[ \t]+"<" begin(StartCondition__::include); <include>{ [^ \t>]+ d_nextSource = matched(); ">"[ \t]*\n switchSource(); .|\n throw runtime_error("Invalid include statement"); } // 기본 값: 다른 모든 것을 std::cout으로 반향한다. .|\n echo();
Scanner
클래스는 flexc++에 의하여 한 번 생성된다. 이 클래스는 ScannerBase
바탕 클래스가 정의한 여러 멤버에 접근한다. 이 멤버 중 어떤 멤버는 공개 접근 권한이 있고 Scanner
클래스의 외부 코드에 사용할 수 있다. 이 멤버들은 flexc++(1) 매뉴얼 페이지에 널리 문서화되어 있다. 더 자세한 정보는 이를 참고하라.
우리의 스캐너는 다음 일을 수행한다.
입력에서 #include
서술문으로 스캐너는 스캔 처리를 계속해야 하는 파일이름을 추출할 수 있다. 이 파일이름은 d_nextSource
지역 변수에 저장된다. 그리고 stackSource
멤버는 다음 소스로의 전환을 처리한다. 다른 것은 요구되지 않는다. 입력 파일을 넣고 빼는 것은 flexc++
가 제공하는 스캐너의 pushStream
멤버와 popStream
멤버가 처리한다. 그러므로 Scanner
의 인터페이스는 switchSource
함수 하나만 더 선언하면 된다.
스트림 전환은 다음과 같이 처리된다. 스캐너가 파일이름을 #include
지시어로부터 추출하면 switchSource
에 의하여 또다른 파일로 전환된다. 이 멤버는 flexc++
가 정의한 pushStream
멤버를 호출하여 현재 입력 스트림을 스택에 넣고 d_nextSource
변수에 저장된 이름의 스트림으로 전환한다. 이것은 또 include
미니-스캐너를 끝낸다. 그래서 스캐너를 기본 스캐닝 모드로 돌려주기 위해 begin(StartCondition__::INITIAL)
함수가 호출된다. 다음은 그 소스이다.
#include "scanner.ih" void Scanner::switchSource() { pushStream(d_nextSource); begin(StartCondition__::INITIAL); }
flexc++
가 정의한 pushStream
멤버는 필요한 모든 점검을 처리하고 파일을 열 수 없거나 스택에 파일이 너무 많으면 예외를 던진다.
flexc++
가 Scanner::lex
에 어휘를 스캔하는 멤버를 정의한다. 이 멤버를 호출하면 스캐너가 돌려주는 토큰을 처리할 수 있다.
Scanner
를 사용하는 프로그램은 아주 단순하다. 스캔 처리를 시작할 곳을 알려주는 파일이름을 기대한다.
프로그램은 먼저 인자의 갯수를 점검한다. 인자가 하나라도 주어지면 그 인자는 두 번째 인자 "-"
와 함께 Scanner
의 생성자에 건네진다. 이 두 번째 인자는 출력이 표준 출력 스트림으로 가야한다고 알려준다.
프로그램이 인자를 두 개 이상 받으면 디버그 출력도 표준 출력 스트림에 씌여진다. 디버그 출력에 어휘 스캐너의 조치가 문서화된다.
다음으로 Scanner
의 lex
멤버가 호출된다. 무엇이든 실패하면 std::exception
예외가 던져진다. 이 예외는 main
의 try-블록에서 catch 절이 잡는다. 다음은 프로그램의 소스이다.
#include "lexer.ih" int main(int argc, char **argv) try { if (argc == 1) { cerr << "Filename argument required\n"; return 1; } Scanner scanner(argv[1], "-"); scanner.setDebug(argc > 2); return scanner.lex(); } catch (exception const &exc) { cerr << exc.what() << '\n'; return 1; }
flexc++
와 Gnu C++ 컴파일러 g++
이 설치되어 있다.
flexc++
를 사용하여 구문 스캐너의 소스를 생성한다. 이를 위해 다음 명령어를 주면 된다.
flexc++ lexer
g++ --std=c++14 -Wall *.cc
Flexc++
는 https://fbb-git.github.io/flexcpp/으로부터 내려받을 수 있다. 그리고 bobcat
라이브러리도 필요하다. http://bobcat.sf.net/에서 내려받을 수 있다.
파서와 스캐너를 모두 사용하는 프로그램을 개발할 때 시발점은 문법이다. 문법에 정의된 토큰 집합은 어휘 스캐너가 돌려줄 수 있다 (이하 스캐너라고 지칭).
마지막으로, 빈 공백을 채우기 위해 보조 코드가 제공된다. 파서와 스캐너가 수행하는 조치들은 정상적으로 지정되지 않는다. 문자 그대로 문법 규칙이나 어휘 정규 표현식으로 지정되지 않는다. 파서의 규칙들이 호출할 수 있도록 멤버 함수들 안에 구현해 넣거나 스캐너의 정규 표현식에 연관지어 주어야 한다.
이전 절에서 flexc++
가 생성하는 C++ 클래스의 예제를 보았다. 현재 절에서는 파서에 집중하겠다. 파서는 bisonc++
프로그램이 처리해 주는 문법 지정 파일로부터 생성할 수 있다. bisonc++
가 요구하는 문법 지정 파일은 bison
(bison++
)이 처리하는 파일과 비슷하다 (bison++
파서는 bisonc++
의 전신으로서 19세기 초에 알레인 꼬에모(Alain Coetmeur)가 작성했다).
이 절은 중위 표현식을 후위 표현식으로 변환하는 프로그램을 개발한다. 중위 표현식은 이항 연산자가 피연산자 사이에 있고 후위 표현식은 이항 연산자가 피연산자 뒤에 위치한다. 또한 단항 연산자 -
는 전위 표기법에서 후위 표기법으로 변환된다. 단항 연산자 +
는 무시한다. 더 이상 처리할 필요가 없기 때문이다. 본질적으로 우리의 작은 계산기는 마이크로 컴퓨터이다. 숫치 표현식을 어셈블리-류의 명령어로 변환한다.
우리의 계산기는 기본적인 연산자 집합을 인지한다. 곱셈, 덧셈, 괄호, 그리고 단항 마이너스를 인지한다. 실수를 정수와 구별하겠다. bison-류의 문법 지정에 있어서 미묘한 점들을 보여주기 위해서이다. 이상이다. 이 절의 목적은 결국 파서와 어휘 스캐너를 모두 사용하는 C++ 프로그램의 생성 방법을 보여주는 것이다. 완벽하게 기능을 갖춘 계산기를 만드는 데 목적이 있지 않다.
다음 목에서는 bisonc++
를 위하여 문법을 지정하는 방법을 개발할 것이다. 다음, 스캐너를 위한 정규 표현식을 지정한다. 그 다음에 최종 프로그램을 만든다.
bisonc++
가 요구하는 문법 지정 파일은 bison
이 요구하는 규격 파일과 비슷하다. 차이점은 결과 파서 클래스의 성격에 있다. 우리의 계산기는 정수와 실수를 구별한다. 그리고 기본적인 산술 연산자 집합을 지원한다.
Bisonc++
는 다음과 같이 사용해야 한다.
bisonc++
에도 이것은 다르지 않다. bisonc++
문법 정의는 실용적 목적으로 bison
의 문법 정의와 동일하다.
bisonc++
는 파서 클래스와 parse
멤버 함수의 구현을 정의한 파일을 생성할 수 있다.
parse
멤버가 적절하게 작동하기 위하여 요구되는 멤버들은 제외하고) 따로따로 구현되어야 한다. 물론, 모두 파서 클래스의 헤더에 선언해야 한다. 최소한 lex
멤버는 구현해야 한다. 이 멤버는 다음 토큰을 얻기 위하여 parse
함수가 호출한다. 그렇지만 bisonc++
는 lex
함수의 표준 구현의 편의기능을 제공한다. error(char const *msg)
멤버 함수는 간단하게 기본만 구현한다. 이를 프로그래머가 변경할 수 있다. parse
함수가 (구문) 에러를 탐지하면 error
멤버 함수가 호출된다.
int main() { Parser parser; return parser.parse(); }
bisonc++
는 또한 여러 새 선언도 지원한다. 이 새 선언들은 중요하며 아래에 논의한다.
bison
이 요구하는 부분과 동일하다. 물론 bison
과 bison++
에 사용할 수 있는 멤버 중에 어떤 멤버는 bisonc++
에서 폐기되었다. 반면에 다른 멤버들은 더 넓은 문맥에 사용할 수 있다. 예를 들어 ACCEPT와 ABORT는 파서의 조치 블록으로부터 호출된 어떤 멤버도 호출하여 파싱 처리를 끝낼 수 있다.
bison
에 익숙한 독자분이라면 헤더 부분이 더 이상 없다는 사실을 눈치채셨을 것이다. 헤더 부분은 bison
이 사용하여 필요한 선언을 제공한다. 이를 이용하여 컴파일러는 bison
이 생성한 C 함수를 컴파일할 수 있다. C++에서 선언은 클래스 정의의 일부이거나 아니면 이미 사용 중이다. 그러므로 파서 생성기는 C++ 클래스를 생성하고 멤버 함수 중 어떤 멤버는 헤더 부분을 더 이상 요구하지 않는다.
bisonc++
에서만 사용할 수 있는 것들을 여기에서 논의한다. 완전한 설명은 bisonc++
의 매뉴얼 페이지를 참조하시기를 바란다.
%baseclass-preinclude header
미리-포함된 파일을 가리키는 경로 이름으로 header
를 파서의 바탕 클래스 헤더에 사용하라. 이 선언은 바탕 클래스 헤더 파일이 아직 알려지지 않은 유형을 참조하는 상황에 유용하다. 예를 들어 %union
으로 std::string *
필드를 사용할 수 있다. 바탕 클래스 헤더 파일을 처리할 때 std::string
클래스가 아직 컴파일러에게 알려져 있지 않기 때문에 이 클래스와 유형에 관하여 컴파일러에게 알려줄 방법이 필요하다. 필요한 유형이 선언되어 있는 미리-포함된 헤더 파일을 사용하기를 제안한다. 기본값으로 header
는 겹따옴표로 둘러싼다 (예를 들어 #include "header"
). 인자를 옆꺽쇠로 둘러 싸면 #include <header>
가 포함된다. 후자의 경우에 쉘이 번역하지 않도록 하려면 겹따옴표가 필요할 수도 있다 (예를 들어 다음 -H '<header>'
을 사용).
%filenames header
특정한 이름으로 재정의되지 않는 한, 생성된 모든 파일의 총칭 이름을 정의한다. 생성된 파일은 클래스 이름을 총칭 파일이름으로 사용하는 것이 기본값이다.
%scanner header
파서의 클래스 헤더에 미리 포함된 파일을 가리키는 경로로 header
를 사용하라. 이 파일은 Scanner
클래스를 정의하고 int lex()
멤버를 제공해야 한다. 이 멤버는 입력 스트림으로부터 다음 토큰을 생산한다. 이 토큰은 bisonc++
가 생성한 파서가 분석한다. 이 옵션을 사용하면 파서의 int lex()
멤버가 다음과 같이 미리 정의된다 (기본 파서 클래스 이름으로 Parser
가 사용된다고 가정한다):
inline int Parser::lex() { return d_scanner.lex(); }그리고
Scanner d_scanner
객체가 파서 안으로 조합되어 들어간다. d_scanner
객체는 기본 생성자로 생성된다. 또다른 생성자가 요구되면 bisonc++
를 사용하여 기본 파서 클래스 헤더 파일을 생성한 후에 파서 클래스에 적절한 (중복정의) 파서 생성자를 공급할 수 있다. 기본으로 header
는 겹따옴표로 둘러 싼다 (예를 들어 #include "header"
). 인자를 옆꺽쇠로 둘러 싸면 #include <header>
가 포함된다.
%stype typename
토큰의 의미구조 값의 유형. typename
에는 구조체가 아니라 유형의 이름을 지정해야 한다 (예를 들어 size_t
). 기본으로 int
이다. bison
에서 YYSTYPE
를 참고하라. %union
이 지정되면 typename
을 사용하면 안된다. 파서 클래스 안에서 이 유형은 STYPE
으로 사용된다.
%union union-definition
bison
선언과 동일하게 행위한다. bison
에서처럼 이것은 파서의 의미구조 유형에 대하여 공용체를 생산한다. 그 공용체 유형은 이름이 STYPE
이다. %union
이 선언되어 있지 않으면 %stype
선언으로 간단한 스택-유형이 정의된다. %stype
선언을 사용하지 않으면 기본 스택 유형이 사용된다 (int
).
%union
선언의 예는 다음과 같다.
%union { int i; double d; };C++11 이전의 코드라면 공용체는 자신의 필드에 객체를 가질 수 없다. 공용체를 생성할 때 생성자를 호출할 수 없기 때문이다. 이것은
string
이 공용체의 멤버가 될 수 없다는 뜻이다. 그렇지만 string *
은 공용체의 멤버로 가능하다. 또 클래스 유형의 객체를 필드로 가질 수 있는 무제한 공용체를 사용하는 것도 가능하다 (12.6절).
사실 스캐너는 그런 공용체에 관하여 알 필요가 없다. 그저 스캔된 텍스트를 matched
멤버 함수를 통하여 파서에 건넬 수만 있으면 된다. 예를 들어 다음과 같은 서술문은
$$.i = A2x(d_scanner.matched());부합된 텍스트를 적절한 유형의 값으로 변환한다.
토큰과 비-터미날은 공용체 필드에 연관지을 수 있다. 이것을 적극 권장한다. 그러면 컴파일러가 유형이 올바른지 점검하기 때문에 유형 불일치를 방지해 준다. 뿐만 아니라 bison
에 종속적인 변수 $$
, $1
, $2
, 등등을 사용할 수 있다. ($$.i
처럼) 필드를 완전하게 지정하지 않아도 된다. 비-터미날 또는 토큰은 공용체 필드에 연관지을 수 있다. <fieldname>
을 지정하면 된다. 예를 들어,
%token <i> INT // 토큰 연관 (비추천, 아래 참고) <d> DOUBLE %type <i> intExpr // 비-터미날 연관여기에서 개발된 예제는 토큰과 비-터미날 모두 공용체 필드에 연관지을 수 있다. 그렇지만 앞서 지적했듯이 스캐너는 이 모든 것에 관하여 알 필요가 없다. 스캐너에게 한 가지 일만 시키는 것이 더 깔끔할 것이다. 텍스트를 스캔하는 일만 시키는 게 좋다. 그 대신 입력이 무엇인지 잘 아는 파서가
"123"
같은 문자열을 정수 값으로 변환할 수 있다. 결론적으로 공용체 필드와 토큰을 연관짓는 것은 권장하지 않는다. 아래에서 문법 규칙을 기술하는 동안 이 점을 더 자세히 보여준다.
%union
을 논의하면서 %token
과 %type
규격을 지적해야겠다. 스캐너가 돌려줄 수 있는 토큰을 (즉, 터미널 심볼을) 지정하고 비-터미날의 반환 유형을 지정한다. %token
말고도 토큰 선언자로서 %left
와 %right
그리고 %nonassoc
를 사용하여 연산자의 연관성을 지정할 수 있다. 이 지시어에 언급된 토큰들은 연산자를 나타내는 토큰으로 번역되어, 지시한 방향에 연관된다. 연산자의 우선 순위는 순서대로 정의된다. 첫 번째 지정된 연산자가 우선 순위가 제일 낮다. 특정한 문맥에서 우선 순위를 바꾸려면 %prec
을 사용할 수 있다. 이 모든 것은 표준 bisonc++
관례이기 때문에 여기에서 더 이상 언급하지 않는다. 더 자세한 정보는 bisonc++
의 배포본에 포함된 문서를 참조하시기를 바란다.
다음은 계산기의 선언 부분의 규격이다.
%filenames parser %scanner ../scanner/scanner.h %union { int i; double d; }; %token INT DOUBLE %type <i> intExpr %type <d> doubleExpr %left '+' %left '*' %right UnaryMinus
선언 부분에 %type
지정자가 사용된다. intExpr
규칙의 값을 (다음 절 참고) 의미구조-값 공용체의 i
-필드에 연관짓고 doubleExpr
의 값을 d
-필드에 연관짓는다. 이 접근법은 솔직히 말해 좀 복잡하다. 지원되는 공용체 유형마다 표현식 규칙을 포함해야 하기 때문이다. 물론 다른 방법이 있다. 그리고 다형적 의미구조 값의 사용이 관련된다. 이에 관해서는 Bisonc++ 사용자 가이드에 더 자세하게 다룬다.
bisonc++
이 제공하는 다양한 특징들을 보여준다. 특히 한 줄의 코드 말고는 어떤 행위 블록도 요구하지 않는다는 것을 눈여겨보라. 이 덕분에 문법이 단순하다. 그러므로 가독성과 이해도가 개선된다. 파서의 적당한 종료를 정의한 규칙조차도 (line
규칙에 있는 빈줄) done
이라고 부르는 멤버 함수 하나만 사용한다. 그 함수의 구현은 간단한다. 그러나 Parser::ACCEPT를 호출한다는 사실을 눈여겨볼 가치가 있다. ACCEPT를 생산 규칙의 행위 블록으로부터 간접적으로 호출한다는 것을 보여준다. 다음은 문법의 생산 규칙이다.
lines: lines line | line ; line: intExpr '\n' { display($1); } | doubleExpr '\n' { display($1); } | '\n' { done(); } | error '\n' { reset(); } ; intExpr: intExpr '*' intExpr { $$ = exec('*', $1, $3); } | intExpr '+' intExpr { $$ = exec('+', $1, $3); } | '(' intExpr ')' { $$ = $2; } | '-' intExpr %prec UnaryMinus { $$ = neg($2); } | INT { $$ = convert<int>(); } ; doubleExpr: doubleExpr '*' doubleExpr { $$ = exec('*', $1, $3); } | doubleExpr '*' intExpr { $$ = exec('*', $1, d($3)); } | intExpr '*' doubleExpr { $$ = exec('*', d($1), $3); } | doubleExpr '+' doubleExpr { $$ = exec('+', $1, $3); } | doubleExpr '+' intExpr { $$ = exec('+', $1, d($3)); } | intExpr '+' doubleExpr { $$ = exec('+', d($1), $3); } | '(' doubleExpr ')' { $$ = $2; } | '-' doubleExpr %prec UnaryMinus { $$ = neg($2); } | DOUBLE { $$ = convert<double>(); } ;
이 문법은 간단한 계산기를 구현한다. 정수 값과 실수 값을 부인할 수 있고 더하고 곱할 수 있다. 그리고 표준 우선 순위 규칙은 괄호로 바꿀 수 있다. 문법은 유형이 있는 비-터미날 심볼의 사용을 보여준다. doubleExpr
는 실수 (배정도) 값에 연결되고 intExpr
는 정수 값에 연결된다. 우선순위와 유형 연결은 파서의 정의 부분에 정의된다.
Bisonc++
는 여러 파일을 생성한다. 그 중에 파서 클래스를 정의하는 파일이 있다. 생성 규칙의 조치 블록으로부터 호출된 함수는 파서의 멤버 함수이다. 이 멤버 함수들을 선언하고 정의해야 한다. 일단 bisonc++
가 파서의 클래스를 정의한 헤더 파일을 생산하고 나면 그 헤더 파일은 자동으로 다시 씌여지지 않는다. 덕분에 프로그래머는 필요할 때마다 새 멤버를 파서 클래스에 추가할 수 있다. 다음은 우리의 작은 계산기에 사용된 `parser.h
' 이다.
#ifndef Parser_h_included #define Parser_h_included #include <iostream> #include <sstream> #include <bobcat/a2x> #include "parserbase.h" #include "../scanner/scanner.h" #undef Parser class Parser: public ParserBase { std::ostringstream d_rpn; // $insert scannerobject Scanner d_scanner; public: int parse(); private: template <typename Type> Type exec(char c, Type left, Type right); template <typename Type> Type neg(Type op); template <typename Type> Type convert(); void display(int x); void display(double x); void done() const; void reset(); void error(char const *msg); int lex(); void print(); static double d(int i); // parse()에 함수들을 지원함: void executeAction(int d_ruleNr); void errorRecovery(); int lookup(bool recovery); void nextToken(); void print__(); }; inline double Parser::d(int i) { return i; } template <typename Type> Type Parser::exec(char c, Type left, Type right) { d_rpn << " " << c << " "; return c == '*' ? left * right : left + right; } template <typename Type> Type Parser::neg(Type op) { d_rpn << " n "; return -op; } template <typename Type> Type Parser::convert() { Type ret = FBB::A2x(d_scanner.matched()); d_rpn << " " << ret << " "; return ret; } inline void Parser::error(char const *msg) { std::cerr << msg << '\n'; } inline int Parser::lex() { return d_scanner.lex(); } inline void Parser::print() {} #endif
Parser::INT
나 Parser::DOUBLE
토큰으로 반환된다.
flexc++
지시어인 %interactive
가 제공된다. 계산기는 사용자인 인간과 적극적으로 상호 작용하는 프로그램이기 때문이다.
다음은 완전한 flexc++
규격 파일이다.
%interactive %filenames scanner %% [ \t] // ignored [0-9]+ return Parser::INT; "."[0-9]* | [0-9]+("."[0-9]*)? return Parser::DOUBLE; .|\n return matched()[0];
bisonc++
와 flexc++
를 사용하여 빌드한다. 다음은 계산기의 main
함수를 구현한다.
#include "parser/parser.h" using namespace std; int main() { Parser parser; cout << "Enter (nested) expressions containing ints, doubles, *, + and " "unary -\n" "operators. Enter an empty line to stop.\n"; return parser.parse(); }
다음 명령어는 파서의 parse.cc
파일과 parserbase.h
헤더를 만들어 낸다.
bisonc++ grammar
parser.h
파일은 한 번만 생성된다. 개발자는 필요하면 멤버를 Parser
클래스에 추가할 수 있다.
flexc++ lexer
g++ --std=c++14 -Wall -o calc *.cc -lbobcat -s메인 프로그램의 소스와 스캐너 그리고 파서 생성기에 의하여 생성된 소스를 컴파일하고 링크할 수 있다. 예제는
A2x
클래스를 사용한다. 이 클래스는 bobcat
라이브러리에 포함되어 있다 (24.8.1.5목) (bobcat
라이브러리는 bisonc++
이나 flexc++
를 제공하는 시스템에서 사용할 수 있다). bisonc++
는 다음에서 내려받을 수 있다.
https://fbb-git.github.io/bisoncpp/.