VC++5.0入門14
CTypedPtrList 97/11/24

こんにちは。急に寒くなってきていますが、みなさん、いかがお過ごしでしょうか。

賢明なみなさんなら、既にお気づきのように、今作っているプログラム「Neko」は、VC付属のチュートリアルのサンプル「Scribble」をなぞっています。前にも言いましたが、VCへの最適な入門はチュートリアルを読破することだと思っています。
ただ、初心者に、いきなりチュートリアルを読め、と言っても、読めないでしょうということで、この入門講座がはじまったのでした。「Scribble」も段階を追って改良していくのですが、たぶん、初心者は最初の段階でストップしてしまいそうだったからです。
あと何回「Neko」を続けるかわかりませんが、そんなに長くはないでしょう。そして、その後なら、チュートリアルを読めるようになっていると思います。

今回は、プログラムの動作上は全く変わらない、内部だけの変更をしようと思います。このように内部を変更するのは、(チュートリアルのサンプルに近づけるということもあるのですが、)将来の拡張を考えてのことです。繰り返しますが、今回の「Neko」の変更は、将来の改良のための改良で、今すぐメリットがあるわけではありません。その点で悩まないようにしてください。
で、その変更(改良)ですが、それは、一筆、一筆を分けて処理しようということです。つまり、、、まず、前回までの「Neko」を考えましょう。マウスの左ボタンを押しながら、マウスを動かすと、動いていったマウスの位置に「猫」という字が書かれていきました。また、同時にその位置がCArrayに次々と記録されるため、ウインドウの再描画もきちんとできるというものでした。
一度マウスの左ボタンを押して、マウスを動かして、左ボタンを離したときにできる一連の文字の列を「一筆(ひとふで)」と呼ぶことにします。この一筆は、まず、CArray(CNekoDocのdataのm_points)に記録されますが、次の一筆も、その次の一筆も、順番に同じCArrayに記録されていくのです。
今回したいことは、これらの一筆、一筆を別々の独立したデータとして、記録するということです。、、、わかったでしょうか。
と、するとどうすれば良いでしょうか?たぶん、こんな風でしょう。まず、左ボタンを押したときに、一筆に対応するクラスのオブジェクトを作り、そのオブジェクトにマウスが動いていくときの位置を記録し、このオブジェクトをどこかに登録する。次に、また左ボタンが押されたときには、また、この一筆のクラスのオブジェクトを作り、、、とやっていくことにするのです。
実は、この「一筆に対応するクラス」が我々のCNekoStrokeなのです。(そんなことを前にちょろっと書きましたが、、、。)左ボタンを押したときに、CNekoStrokeのオブジェクトをひとつ作る、ということは、new演算子を使えばうまく行きそうです。つまり、オブジェクトをnewで作り、そのオブジェクトへのポインタをどこかに登録するようにすれば良いのです。
そうすると、たくさんの「一筆(CNekoStrokeのオブジェクト)」を登録する何か、つまり、CNekoStrokeオブジェクトへのポインタをまとめておく何かが必要になるのです。実は、このようにいろいろなものを集めておくものをコンテナと言います。配列はもっとも原始的なコンテナです。CArrayもコンテナです。MFCには全部でどれだけのコンテナがあり、それぞれどんな長所短所を持つかは、ヘルプを見てください。
今回は、CTypedPtrListを使うことにします。これは、MFCの提供するコンテナのひとつで、今のように、ポインタをまとめておくのに使えるのです。実際、CNekoStrokeのオブジェクトへのポインタを格納するコンテナは
    CTypedPtrList<CObList, CNekoStroke*> m_strokes;
などとすれば良いのです。ここで<と>の間の最初のCObListはこのコンテナのタイプを決めています。きちんと理解するには、ヘルプを読まなくてはなりませんが、ここでは省かせてください。簡単に言うと、最初の項をCObListにしておくと、シリアライズが使えるということです。(以下を参照してください。)
次の項は格納するオブジェクトの型を表しています。newで返されるものは、CNekoStrokeへのポインタ(CNekoStroke*)なので、そう書いてあるのです。
さて、NekoDoc.hを開いてCNekoDocクラスを見てください。その中に、
    CNekoStroke data;
という項目があるはずです。これを上のコード(CTypedPtrList、、、)に置き換えれば良いのです。
こうして、CNekoStroke(へのポインタ)を複数格納できるようになりました。つまり、CNekoStrokeを作っては、順に、m_strokesに格納(登録)すれば良いのです。
今更ですが、CTypedPtrListって、どう使うんだと思いましたか?それはビューのところで、実際にコードを書きながら考えましょう。
さて、その、ビューです。基本的に何をすれば良いかは、もうわかっていますね。OnLButtonDownで、CNekoStrokeをnewで生成し、そのポインタ(アドレス)をm_strokesに格納すれば良いのです。ただ、そのために、とりあえずnewで作ったオブジェクトのアドレスを一時的に記録しておく変数、つまりCNekoStrokeへのポインタ(つまり、CNekoStroke*)が必要です。これは、CNekoViewのデータメンバにしてしまいましょう。つまり、NekoView.hを開いて、CNekoViewの中の好きなところに
protected:
    CNekoStroke* m_pCurStroke;  //現在のStrokeへのポインタ
と書いてください。protected:というのは、このデータはCNekoViewとその派生クラスでだけ使います、という意味です。このデータメンバはCNekoViewが作業をするのに使うだけなので、public:は良くないでしょう。private:にしなかった理由は、派生クラスを作った場合を考慮してですが、実際には、派生クラスは作らないので、private:でも問題ありません。
いよいよ今回の本題です。NekoView.cppを開いて、OnLButtonDownを
void CNekoView::OnLButtonDown(UINT nFlags, CPoint point) 
{
    SetCapture();  //マウスをキャプチャ(補足)する
    m_pCurStroke=new CNekoStroke;
    CNekoDoc *pDoc=GetDocument();
    pDoc->m_strokes.AddTail(m_pCurStroke);
}
としてください。
マウスをキャプチャした後に、CNekoStrokeを生成し、そのアドレスをm_pCurStrokeに記録しています。次に、ドキュメントへのポインタを取得し、ドキュメントのデータメンバ(CTypedPtrListの)m_strokesにm_pCurStrokeにあるアドレスを格納しているのです。この格納は、CTypedPtrListのメンバ関数AddTailで行っています。この関数は、CTypedPtrListというコンテナの末尾にデータを格納する関数なのです。わかりやすいですよね。
ちょっと、待て。なんで今格納しちゃうんだ。一筆書き上げた後が正しいんじゃないか、と思ったかもしれません。でも、ここで格納しているのは、一筆(CNekoStroke)のオブジェクトそのものではなく、そのオブジェクトへのポインタです。その場合、一筆の内容が変わっても(つまり、CPointを付け加えても)アドレスが変わらなければ、何も問題は無いのです。それで、最初に格納してしまいました。
次に、OnMouseMoveを
void CNekoView::OnMouseMove(UINT nFlags, CPoint point) 
{
    //マウスをキャプチャしていなければ、何もしない
    if(GetCapture()!=this)
        return;
    m_pCurStroke->m_points.Add(point);
    CClientDC dc(this);
    dc.TextOut(point.x, point.y, m_pCurStroke->str);
}
としてください。
ここでは、現在の(作業用に現在の一筆のアドレスを記録している)m_pCurStrokeを使っています。前回のコードよりわかり易くなっているのではないでしょうか?
OnLButtonUpに変更はありません。
あと、何をし残したでしょうか?、、、OnDraw!と思った人は、とても良くわかっていると思います。 OnDrawを以下のように書き換えてください。
void CNekoView::OnDraw(CDC* pDC)
{
    CNekoDoc* pDoc = GetDocument();
    ASSERT_VALID(pDoc);
    
    CTypedPtrList<CObList, CNekoStroke*>& strokes=pDoc->m_strokes;
    //すみません。前回のアップロード版では上の<CObList, CNekoStroke*>が抜けていました。
    //HTMLのタグとテンプレートの<、>が混同されてしまったのです。
    POSITION pos=strokes.GetHeadPosition();
    while(pos!=NULL){
        CNekoStroke* pStroke=strokes.GetNext(pos);
        pStroke->DrawNekoStroke(pDC);
    }
}
はじめの2行はAppWizardが書いたものでした。これでドキュメントへのポインタを取得しているのですね。その後で、ドキュメントのm_strokesへの参照strokesを作っています。
参照???私のC++入門ではちゃんとやっていませんでした。(後で補足します。今回は簡単で、許してください。)上のように書くと、strokesとpDoc->m_strokesが同じものになるのです。(ポイントは&があることです。)なぜ、そんなことをしたのかと言うと、ここでは、何度もpDoc->m_strokesと書くのが面倒だから、ただそれだけです。面倒なことをすればわかりずらくなって、間違えやすくなりますよね。
さて、次にPOSITION変数posを定義しています。この変数はCTypedPtrListのデータの位置を表す変数です。ちょっと、しっくりこないかもしれませんが、たとえば、配列を考えてみましょう。整数の配列hairetu[100]内のデータはhairetu[0]、hairetu[1]、、、などと呼ばれます。20番目の要素は?と聞かれたら、hairetu[19]ですね。(C++では数は0から数えます。^^)このようにコンテナ(コンテナとはものをたくさん入れておく入れ物のことで、配列もコンテナの一種と書きましたね)内のデータには、場所を表す方法が必要なのです。配列の場合、データの場所は整数で表せたのです。(上の例では0、1、、、19、、、ですね。)
しかし、一般のコンテナではそうは行かないのです。そして、私たちが現在使っているCTypedPtrListではPOSITIONという変数が使われるのです。ピンとこないかもしれませんが、コードを見ているとなんとなくわかってくると思います。
まず、CTypedPtrListのメンバ関数GetHeadPositionを使って、コンテナ内の最初のデータのPOSITIONを取得し、posに代入しています。(というかそのように初期化しているのですね。)そして、whileのループです。まず、中身を見てください。CTypedPtrListのメンバ関数GetNextが使われています。この関数は名前から想像できるように、posをひとつずらします。そのときに、格納しているデータを戻して(返して)くれるので、それをpStrokeに記録し、CNekoStrokeのメンバ関数DrawNekoStrokeで、描画しているのです。GetNextはコンテナの最後のデータに行き着くと、NULLというもの(何も無い、という意味の記号)を戻すので、ループをどれだけ繰り返すかはposがNULLかどうか見ていれば良いのです。これで、whileループはわかったのではないでしょうか?
これでほぼ終わりですが、、、まだ、やり残しも有ります。え〜と、まず、シリアライズですね。
void CNekoDoc::Serialize(CArchive& ar)
{
    if (ar.IsStoring())
    {
        // TODO: この位置に保存用のコードを追加してください。
    }
    else
    {
        // TODO: この位置に読み込み用のコードを追加してください。
    }
    m_strokes.Serialize(ar);  //ここを変更
}
としてください。CTypedPtrListには(CObListを使った場合)Serializeが定義されているので、上のように使えば良いだけです。こうすると、CTypedPtrListの各データのSerializeが呼び出されるのです。便利ですね。
さあ、最後です。後、何が必要でしょう。、、、newを使ったので、deleteしておきたいですね。このくらいの小さいプログラムでは問題にならないかもしれませんが、newにはdeleteを、というスローガンには従っておきましょうね。
場所はDeleteContentsという関数です。この関数も、OnDrawなどと同様に、ドキュメントに用意された特別な関数です。この関数は、ドキュメントの内容が破棄されるときに、フレームワークによって呼び出されます。(例えば、メニューで「新規作成」を選択した時なんかに呼び出されます。また、ドキュメント自身が破棄される前にも、自動的に呼び出されます。)
やり方はいろいろあるようですが、今回はクラスビューを使って、コードの書き込みをしましょう。まず、Developer Studioの左の方にあるクラスビュー(ClassView)のタブをクリックして、CNekoDocをマウスの右ボタンでクリックしてみてください。するとポップアップメニューがでますが、ここで、仮想関数の追加を選択してください。そこで、出てきたダイアログの左の窓でDeleteContentsを選択し、右の方にある「追加と編集」ボタンを押してください。
これで、必要なコードが自動で記述され、プログラマが書き込む場所も指定されます。そこに、書き足して、全体で、
void CNekoDoc::DeleteContents() 
{
    while(!m_strokes.IsEmpty()){
        delete m_strokes.RemoveHead();
    }
    CDocument::DeleteContents();  //元からあるコード
}
としてください。書き足したのはwhileループです。CTypedPtrListのメンバ関数RemoveHeadはコンテナの先頭を削除するという意味ですが、これはコンテナの問題で、コンテナ内のポインタに示されているデータ(これはコンテナの外にありますね)はそのまま残されます。ただ、そのデータはこの関数で戻されるので、間髪をいれずにdeleteしてしまえば良いのです。そうして次々にコンテナを縮め、データを削除して行くのですが、どこまでやれば良いかは、IsEmptyで調べます。この関数はコンテナ内に何かが残っている場合には0を、残っていない場合には0以外の値を戻すことになっています。(初心者には耳慣れないかもしれません。CやC++では「0を戻す」「0以外の値を戻す」という関数が多いのです。「0」に演算子「!」を適用すると「0以外の値(たぶん1)」になり、「0以外の値」に「!」を適用すると「0」になります。そして、whileの()内は、0以外だとループが続行され、0だとループから抜けるようになっているのです。要するに、)このためwhile(!m_strokes.IsEmpty())で、コンテナ内のデータがある限りループを続け、データが無くなったらループを修了させるという意味になるのです。
これで、とりあえず、おしまいです。あまり簡単ではありませんね。ただ、コードを見ているとわかってくる(しっくりくる)と思いますので、あまり早めにあきらめないでください。(私は、最近、あきらめかけた時は、サッカーのイラン戦を思い出すことにしています。^^;日本人でない方、サッカーの嫌いな方、失礼しました。)

コードを書き込んだら、実際に実行してみてください。プログラムの動作は変わっていないと思います。ただ、メニューの新規作成を選択すると、画面が一新されるようになりました。これは、CTypedPtrListを使ったからではなく、DeleteContentsをちゃんと書いたからです。メニューの新規作成が選択されると、DeleteContentsが呼び出され、ついでOnDrawが呼び出されるのです。
今回はここまでにしましょう。
私の講座を毎週読んでくれているみなさんありがとうございます。実は、また、ちょっと忙しくなったので、3週ほど休ませてください。いろいろ仕事が終わらないのです。すみません。それで、次回は、12月21日の予定です。
目次のページ
前のページ
後のページ