상세 컨텐츠

본문 제목

계산기용 스캐너 만들기 - State Pattern

etc

by ryujt 2010. 11. 4. 16:10

본문

이번에는 순수 코딩으로 스캐너를 작성하는 방법을 살펴보도록 하겠습니다.  강의를 통해서 스캐너를 작성하는 과정을 선보인 적이 있었는데, 너무 복잡해서 어려워하시는 분들이 계셨던 경험을 살려서, 오늘은 규칙을 다소 간단하게 만들어 봤습니다.

제가 배포하고 있는 State Pattern 동영상 강좌에서도 같은 내용을 설명하는 것이 있으니 참고하시기 바랍니다.

[그림 1] 스케너 규칙을 State Diagram으로 표기

[그림 1]에 대한 설명은 아래와 같습니다.
  • 모든 상태의 초기는 Normal로 한다.  문자 하나를 읽어서 규칙에 맞는 상태로 전이된다.  
  • Normal에서 [a-zA-Z] 즉, 알파벳 문자가 입력되면, 상태는 Identifier로 전이된다.  이후 계속 문자를 입력 받으면서 [a-zA-Z0-9] 즉, 알파벳 또는 숫자가 입력되면 상태를 유지하게 된다.  이는 대부분의 프로그래밍 언어에서 식별자를 생성하는 규칙을 응용했다.  언더바와 같은 문자를 추가하려면 규칙을 변경하면 된다.  
  • Operator의 경우에는 어떤 문자가 입력되더라도, Normal로 복귀할 수 밖에 없기 때문에 default라고 표기했다.
이제 남은 것은 코드로 옮기는 일 뿐입니다.  스테이트 패턴을 이해하고 있다면 코드로 옮기는 것은 아주 쉽습니다.  더구나, 각 상태에 대해서 자신이 가질 수 있는 경우 수만을 생각하면 되기 때문에, 전체의 흐름을 이해할 필요도 없습니다.

반복적인 프로세스가 어떤 상태(플래그)에 따라서 행동이 달라져야 한다면, State Pattern을 적용하여 이것을 고려해 볼 필요가 있습니다.  상태의 흐름이 복잡할 수록 플래그와 조건 분기문을 이용하는 것보다 State Pattern을 사용하는 편이 더욱 유리해 집니다.

[그림 2] Class Diagram

[그림 2]는 State Pattern을 이용하여 설계된 스캐너의 Class Diagram 입니다.  TScanner가 Context에 해당하며, TState 이후 상속되어 파생되는 클래스들은 일단 생략하였습니다.  

TScannerStream은 데이터를 입력받는 역할을 담당합니다.  이후 확장을 고려하여, 따로 클래스로 뽑아냈습니다.  현재는 문자열을 지정하는 형식이지만, 추후 파일이나 메모리에서 입력받는 데이터를 사용할 수 있도록 할 예정입니다.

TInterfaceBase는 TScanner가 Interface를 상속받아서 구현하도록 되어 있기 때문에, 기본적으로 구현이 필요한 QueryInterface, _AddRef, _Release 메소드를 상속받아서 코드를 간편하게 하기 위해서 작성했습니다.

IScanner는 각각의 상태를 표현하는 객체들이 Context를 호출하기 위해서, 필요 이상으로 Context의 멤버를 접근하는 것을 방지하기 위해, 상태 객체가 꼭 필요한 호출을 제공하도록 하였습니다.

아래는 각 클래스의 Public 멤버들의 설명입니다.
  • TScanner
    • procedure Execute(AText:string);
      • AText 문자열을 스캐닝 하여 토큰으로 분리하여 아래의 OnToken 이벤트를 발생시킨다.
    • property OnToken : TTokenEvent;
  • IScanner
    • procedure SetState(AState:TObject; AData:char);
      • Scanner 객체를 AState 상태로 변경하고자 할 때 호출한다.
      • AData가 #0 이면 새로 데이터를 입력받아서 스캐닝을 계속하고, 이외의 문자이면 데이터를 입력받지 않고, AData를 이용해서 스캐닝을 계속 해나간다.
    • procedure SetNormalState(AData:char);
      • NormalState로 변경한다.
    • procedure ExpressToken(ATokenType,AToken:string);
      • 토큰 식별이 완료되어 Scanner 객체의 OnToken 이벤트를 발생한다.
  • TState
    • procedure Scan(AData:char); virtual; abstract;
      • 토큰 식별을 위해서 AData에 대한 검사를 진행한다.
    • procedure ActionIn(AOld:TState); virtual;
      • 다른 상태에서 자신의 상태로 진입하였다.
    • procedure ActionOut(ANew:TState); virtual;
      • 자신의 상태에서 다른 상태로 전이한다.
  • TScannerStream
    • procedure Clear;
      • 스트림 객체 내의 모든 데이터를 삭제한다.
    • procedure LoadString(AText:string);
      • AText를 스트림 객체로 로딩한다.
      • 추후 메모리 또는 파일을 지정할 수 있도록 변경되야할 인터페이스 이다.
    • function ReadChar:char;
      • 문자 하나를 스트림에서 읽어 온다.
    • function EOF:boolean;
      • 더 이상 문자를 읽을 수가 없다.
전체 흐름에 대한 이해를 돕기 위해 우선 Scanner.pas 유닛의 구현을 살펴보도록 하겠습니다.

[Code delphi]
unit Scanner;

interface

uses
  ScannerUtils, ScannerState, NormalState,
  Classes, SysUtils;

type
  TScanner = class (TInterfaceBase, IScanner)
  private
    FState, FNormalState : TState;
    FStream : TScannerStream;
    procedure SetState(AState:TObject; AData:char);
    procedure SetNormalState(AData:char);
    procedure ExpressToken(ATokenType,AToken:string);
  private
    FOnToken: TTokenEvent;
  public
    constructor Create;
    destructor Destroy; override;

    procedure Execute(AText:string);
  public
    property OnToken : TTokenEvent read FOnToken write FOnToken;
  end;

implementation

{ TScanner }

constructor TScanner.Create;
begin
  inherited;

  FNormalState := TNormalState.Create(Self, FStream);
  FStream := TScannerStream.Create;

  FState := FNormalState;
end;

destructor TScanner.Destroy;
begin
  FreeAndNil(FStream);

  inherited;
end;

procedure TScanner.Execute(AText: string);
var
  Ch : char;
begin
  FState := FNormalState;

  FStream.LoadString(AText);
  try
    Ch := FStream.ReadChar;
    while not FStream.EOF do begin
      FState.Scan(Ch);
      Ch := FStream.ReadChar;
    end;

    // Stream의 마지막까지 읽었으나 해당 상태의 탈출조건을 만나지 못한 경우이다.
    if FState <> FNormalState then SetNormalState(#0);      
  finally
    FStream.Clear;
  end;
end;

procedure TScanner.ExpressToken(ATokenType, AToken: string);
begin
  if Assigned(FOnToken) then FOnToken(Self, ATokenType, AToken);
end;

procedure TScanner.SetNormalState(AData: char);
begin
  SetState(FNormalState, AData);
end;

procedure TScanner.SetState(AState: TObject; AData: char);
var
  OldState, NewState : TState;
begin
  OldState := FState;
  NewState := Pointer(AState);

  OldState.ActionOut(NewState);
  FState := NewState;

  NewState.ActionIn(OldState);
  if AData <> #0 then NewState.Scan(AData);
end;

end.[/Code]

49: 라인을 보면 스캐닝을 위한 Execute 메소드 구현이 시작됩니다.

57: 라인에서는 스트림에서 하나의 문자를 읽어오고, 스트림이 더 이상 문자를 읽어올 수 없을 때까지 반복합니다.  반복하는 동안, 현재 상태를 나타내는 FState 객체의 Scan 메소드를 호출하는 것을 확인 하실 수가 있습니다.

64: 라인에서는 스트림 마지막까지 처리했으나, 현재 상태의 탈출 조건을 만나지 못한 경우에 Normal State로 무조건 복귀하도록 하여, 현재까지 처리된 내용을 출력하도록 하였습니다.  이 부분이 생략된다면, 마지막 토큰은 무시될 수도 있습니다.

70: 라인은 각 상태 객체가 자신의 처리를 마치고 Normal State로 복귀하면서 자신이 식별한 토큰을 이벤트로 출력하기 위한 메소드를 구현한 것 입니다.  IScanner Interface를 통해서 호출 됩니다.

75: 각 상태 객체가 자신의 처리를 마치고 Normal State로 복귀하고자 할 때 호출하는 메소드 입니다.  IScanner Interface를 통해서 호출 됩니다.

80: 현재 상태에서 다른 상태(AState)로 전이하고 싶을 때 호출하는 메소드 입니다.  91: 라인을 통해서 AData 인수에 #0 문자가 입력되지 않고, 다른 문자가 입력되면, 스트림에서 읽지 않고 바로 Scan 메소드를 호출하여, 토큰 식별을 하도록 합니다.

아래는 NormalState.pas 유닛 소스 입니다.

[Code delphi]
unit NormalState;

interface

uses
  ScannerUtils, ScannerState,
  NumberState, IdentifierState, OperatorState,
  Classes, SysUtils;

type
  TNormalState = class (TState)
  private
    FNumberState : TNumberState;
    FIdentifierState : TIdentifierState;
    FOperatorState : TOperatorState;
  public
    constructor Create(AOwner:TObject; AStream:TScannerStream); override;
    destructor Destroy; override;

    procedure Scan(AData:char); override;
  end;

implementation

{ TNormalState }

constructor TNormalState.Create(AOwner: TObject; AStream: TScannerStream);
begin
  inherited;

  FNumberState := TNumberState.Create(AOwner, AStream);
  FIdentifierState := TIdentifierState.Create(AOwner, AStream);
  FOperatorState := TOperatorState.Create(AOwner, AStream);
end;

destructor TNormalState.Destroy;
begin
  FreeAndNil(FNumberState);
  FreeAndNil(FIdentifierState);
  FreeAndNil(FOperatorState);

  inherited;
end;

procedure TNormalState.Scan(AData: char);
begin
  case AData of
    '0'..'9': FScanner.SetState(FNumberState, AData);
    'a'..'z', 'A'..'Z': FScanner.SetState(FIdentifierState, AData);
    '+', '-', '*', '/': FScanner.SetState(FOperatorState, AData);
    else ;      
  end;
end;

end.[/Code]

13-15: 라인을 보시면, Normal State에서 전이될 수 있는 상태를 내타낼 멤버들이 선언되어 있습니다.  이전 강의에서는 Context에 해당하는 클래스가 모든 상태를 표시하는 멤버를 소유하도록 하였습니다.  그로 인해서 Context가 필요없이 복잡해졌던 것이 불만이었습니다.  따라서, 이번에는 모든 상태를 트리 구조로 작성해 나가는 방식을 선택하였습니다.  즉, 자신에게서 전이될 수 있는 상태만을 멤버로 가지게 됩니다.  Normal State 만은 예외로 Context에 선언되어 있기 때문에, 이에 대해서는 IScanner Interface를 통해서 참조 과정을 캡슐화 하였습니다.

45: 라인부터 실제 토큰을 식별하는 프로세스인 Scan 메소드의 구현입니다.  [그림 1]의 State Diagram을 그대로 코드로 옮겨 온 것입니다.  '0'..'9' 즉, 숫자에 해당하는 문자가 입력되면 Number State로 전이하고, 알파벳의 경우에는 Identifier State, 그리고 사칙연산에 해당하는 문자는 Operator State로 전이되도록 하였습니다.  Normal State는 방금 읽은 문자 AData를 처리 할 이유가 없기 때문에, 각각 전이되는 상태 객체에게 FScanner.SetState 메소드를 호출하면서 AData를 인수로 넘겨주게 됩니다.

이제 숫자에 해당하는 토큰을 식별하는 NumberState.pas 유닛을 살펴보도록 하겠습니다.

[Code delphi]
unit NumberState;

interface

uses
  ScannerUtils, ScannerState,
  Classes, SysUtils;

type
  TNumberState = class (TState)
  private
    FNumber : string;
  public
    procedure Scan(AData:char); override;

    procedure ActionIn(AOld:TState); override;
    procedure ActionOut(ANew:TState); override;
  end;

implementation

{ TNumberState }

procedure TNumberState.ActionIn(AOld: TState);
begin
  FNumber := '';
end;

procedure TNumberState.ActionOut(ANew: TState);
begin
  FScanner.ExpressToken('Number', FNumber);
end;

procedure TNumberState.Scan(AData: char);
begin
  case AData of
    '0'..'9': FNumber := FNumber + AData;
    else FScanner.SetNormalState(AData);
  end;
end;

end.[/Code]

24: 라인을 보면, Number State로 전이되자 마자 실행되는 ActionIn 메소드의 구현이 보입니다.  FNumber 라는 문자열 변수를 초기화 합니다.

34: 라인에서는 토큰 식별을 위한 Scan 메소드의 구현입니다.  [그림 1]의 State Diagram에서처럼 숫자에 해당하는 문자가 계속 입력되면, FNumber에 문자열을 추가할 뿐, 전이가 일어나지 않습니다.

38: 라인은 숫자 이외의 문자가 입력되었을 때, Normal State로 전이하도록 합니다.  이때, 상태가 변경되면서, 29: 라인의 ActionOut 메소드가 실행됩니다.  이후, 31: 라인에서처럼 식별된 토큰을 이벤트를 이용해서 출력해 줄 것을 요구하게 됩니다.

나머지 상태를 나타내는 클래스(유닛)들도 같은 방식으로 처리되어 있습니다.  다만, Operator State의 경우에는 전이되자 마자 아무런 조건없이 바로 Normal State로 복귀하게 되어 있습니다.  ([그림 1] 참고)

아래는 조건없이 Normal State로 전이하도록 되어 있는 OperatorState.pas 유닛의 일부분 입니다.

[Code delphi]
procedure TOperatorState.Scan(AData: char);
begin
  FScanner.ExpressToken('Operator', AData);
  FScanner.SetNormalState(#0);
end;[/Code]

3: 라인에서 이벤트 출력을 한 이후, 4: 라인에서 바로 Normal State로 복귀합니다.  이벤트 호출의 경우, NumberState.pas 유닛과 같이 ActionOut을 이용해도 되지만, 단문자와 같이 처리 과정이 간단한 경우에는 바로 이벤트를 호출하는 편이 소스가 깔끔해 보입니다.

이번 강의는 여기까지 마무리 하도록 하고, 이후 좀 더 복잡한 상태도를 통해서 다른 이슈들을 다뤄보도록 하겠습니다.  예를 들어 314E-2와 같은 지수식 표현을 처리하기 위해서는 좀 더 고민을 해야하기 때문입니다.

아래 [그림 3]은 첨부의 예제를 실행시킨 결과 입니다.  

[그림 3] 'Ryu 123 + 456'의 스캐닝 결과

작성된 스캐너의 사용은 아래와 같이 간단 합니다.

[Code delphi]
procedure TfmMain.FormCreate(Sender: TObject);
begin
  FScanner := TScanner.Create;
  FScanner.OnToken := on_Token;

  FScanner.Execute('Ryu 123 + 456');
end;

procedure TfmMain.FormDestroy(Sender: TObject);
begin
  FScanner.Free;
end;

procedure TfmMain.on_Token(Sender: TObject; ATokenType, AToken: string);
begin
  moMsg.Lines.Add(Format('%s: %s', [ATokenType, AToken]))
end;[/Code]



[첨부 1] 본문에 사용된 UML 문서 (Visio)

[첨부 2] 소스 파일

'etc' 카테고리의 다른 글

프로젝트 개요 - 컴파일러 제작  (0) 2010.11.10
기술에 대한 가치 평가  (1) 2010.11.08
계산기용 스캐너 만들기 - Lex 편  (0) 2010.11.01
계산기  (0) 2010.10.27
영풍문고의 서비스  (0) 2010.10.14

관련글 더보기