ホーム » スタッフ » 斉藤徹 » 講義録 » 情報構造論 (ページ 10)

情報構造論」カテゴリーアーカイブ

2025年6月
1234567
891011121314
15161718192021
22232425262728
2930  

検索・リンク

情報構造論ガイダンス2022

基本的なガイダンス

情報構造論のシラバスを、ここに示す。プログラムを作成する上で、どのような考え方で作れば処理速度が速いのかを議論する。基本的に、4回のテストのたびに、レポート課題を実施する。各テスト毎の評価は、テスト素点と、「テスト素点×60%+レポート評価×40%」の良い方とする。テストに自信のない人は、レポート課題をきちんと提出すること。

プログラムを評価する3つのポイント

まずは以下を読む前に、質問。

  • あなたが”良い”プログラムを作るために何を考えて作りますか? ※1
    • ここまでの段階で3つの要点を考えメモしてください。

具体的な言葉で要点を考えると、いろいろなものがでてくるだろうが、端的なポイントにまとめると、次の3つに分類できるはずである。

  • プログラムの速度
  • プログラムのわかり易さ
  • メモリの使用量

プログラムを作る場合、この3要素がトレードオフの関係にある。プログラムの速度を優先すると、プログラムが分かり難くなったり、メモリを大量浪費するものだったりする。

メモリの使用量の影響

メモリを大量に使用すると、どういった影響がでるのか? OSの機能を知らないと、メモリ(主記憶)を使い果たしたら、プログラムが動かないと思うかもしれないけど、最近のOSは仮想メモリ機能があるため、主記憶がメモリが足りなければ待機状態のプロセスのメモリを補助記憶に保存することで、プログラムを動かすことはできる。(仮想記憶)

しかし、プロセスが切り替わる度に、補助記憶への読み書きが発生するため、処理性能は低下する。(スワッピング)

int 型のメモリ使用量

int 型は、プログラムで扱う一般的な整数を扱うのに十分なデータ型。

32bit の0/1情報の組み合わせで、232通りの情報が表現でき、負の数も扱いたいことから2の補数表現を用いることで、-231~0~231-1 の範囲を扱うことができる。231 = 2×210×210×210 ≒ 2×10003

32bit = 4byte

ソフトウェアとアルゴリズムとプログラム

用語として、ソフトウェア、アルゴリズム、プログラムという表現があるが、この違いは何か?

  • アルゴリズム – 計算手順の考え方。
  • プログラム – アルゴリズムを特定のプログラム言語によって記述したもの。
  • ソフトウェア – プログラムと、その処理に必要なデータ。
    (日本語を変換するプログラムは、日本語の辞書データが無いと動かない/役に立たない)
  • パラダイム – プログラムをどう表現すると分かりやすいか?

トレードオフ関係をプログラムで確認

例えば、配列の中から、目的データを探すプログラムの場合、最も簡単なプログラムは以下の方法であろう。

// ((case-1))
// 単純サーチ O(N)
#define SIZE 1024
int a[ SIZE ] ; // 配列
int size ;      // 実際のデータ数(Nとする)
int key ;       // 探すデータ
for( int i = 0 ; i < size ; i++ )
   if ( a[i] == key )
      break ;

しかし、もっと早く探したいのであれば、2分探索法を用いるだろう。でも、このプログラムは、case-1 のプログラムよりは分かり難い。(速度⇔わかり易さ)

// ((case-2))
// 2分探索法 O(log N)
int L=0 , R=size ; // プログラムは複雑になった 
while( L != R ) {
   int M = (L + R) / 2 ;
   if ( a[M] == key )
      break ;
   else if ( a[M] < key )
      L = M + 1 ;
   else
      R = M ;
}

でももっと速いプログラムとしたければ、大量のメモリを使えば一発でデータを探せる。(速度⇔メモリ使用量)

// ((case-3))
// 添字がデータ O(1)
// 探すデータが電話番号 272925 のような 6 桁ならば
int a[ 1000000 ] ;
a[ 272925 ] = 272925 ;
// 処理速度はクソ速いけど、メモリは大量消費

良いプログラムを作るとは

プログラムを作る時には、メモリが大量に使えるのなら、速いものを使えばいい。だけど実際には、そのシステムには限られた予算があるだろう。

実際には、限られた予算からメモリやCPUが決まり、その会社の人員やら経験やらでプログラム開発に使える時間がきまる。プログラムをデザインするとは、限られた条件の中で、適切な速度のコンピュータ、適切な量のメモリでコンピュータを用意し、限られた納期の中でシステムを完成させることである。

皆さんも、ゲームを買った時、処理速度が遅くてキャラクターがカクカク動いたら幻滅するでしょ?ゲームがバグですぐに変な動きしたらキレるでしょ!発売日の予定どおりに買えなかったらさみしいでしょ!!プログラムがでかすぎてローディングに時間がかかったら、寝ちゃうでしょ!!!

2021年度授業アンケート

年度末恒例の授業アンケート結果の総まとめ

情報制御基礎(3年学際科目)

3年の他学科学生も受講する学際科目なので、内容には苦労するけど、ポイントとしては良い値。授業資料をできれば印刷配布してほしいとの意見もあったけど、講義資料やテスト過去問題などもWeb公開しているので、事前に各自対応としたい。

情報構造論(4EI)

若干ポイントも昨年度より上がっているけど、アンケート回答数をみると未回答の人もある程度いるようで、まじめに授業を受けた人がまじめにアンケートに回答してくれたことの影響が多いと思う。マニアックなネタを興味もってくれたり、既に習っている内容がアルゴリズムとしてどういう意味を持っているのかの理解になったとの積極的な意見があってよかった。

データベース(5EI)

こちらもポイントは若干上がっているけど、アンケート回答数からすれば、まじめな授業参加者のおかげかな。他の授業も含め、全講義資料のWeb公開、テスト過去問題の公開に加え、SQLの演習環境をWebで学内向け公開なども実施しているし、好印象を持ってもらえたと思う。

オブジェクト指向プログラミング(専攻科生産システム2年)

昨年度の92.6ポイントより、88.9ポイントと若干低下しているが、概ね良い評価であり、今後も授業の内容を時節に応じて変化させながら実施していきたい。

情報構造論2021全講義録

情報構造論とオブジェクト指向

データ構造を扱うプログラムの書き方を説明してきたが、その考え方をプログラムにするためには手間もかかる。こういった手間を少しでも減らすために、プログラム言語が支援してくれる。その代表格がオブジェクト指向プログラミング(Object Oriented Programming:略称OOP)であり、以下にその基本を説明する。

データ指向のプログラム記述

名前と年齢のデータを扱うプログラムをC言語で書く時、私なら以下のようなプログラムを作成する。

このプログラムの書き方では、saitohというデータにset_NameAge() , print_NameAge() を呼び出していて、データに対して処理を加えるという雰囲気がでている。(C言語なのでデータに処理を施す関数には、必ずどのデータに対する処理なのかを与えるポインタがある。) このようにプログラムを書くと、saitoh というデータに対して命令するイメージとなり、擬人化したデータに向かってset,printしろ…って命令しているように見える。

// 名前と年齢の構造体 
struct NameAge {
   char name[ 20 ] ;
   int  age ;
} ;

// NameAgeを初期化する関数
void set_NameAge( struct NameAge* p , char s[] , int a ) {
   strcpy( p->name , s ) ;
   p->age = a ;
}

// NameAgeを表示する関数
void print_NameAge( struct NameAge* p ) {
   printf( "%s %d¥n" , p->name , p->age ) ;
}

void main() {
   struct NameAge saitoh ;

   set_NameAge( &saitoh, "t-saitoh" , 53 ) ;
   print_NameAge( &saitoh ) ;

   // NameAge の中身を知らなくても、
   // set_NameAge(),print_NameAge() の中身を見なくても、
   // saitoh を set して print する....という雰囲気は伝わるよね!!  
}

このプログラムでは、例えば、データに誕生日も覚えたいという改良を加えるとしても、main の前のデータ構造と関数の部分は色々と書き換えることになるだろうけど、main の内部はあまり変わらないだろう。こういう書き方をすればプログラムを作成するときには、データ構造とそれを扱う関数を記述する人と、データ構造を使う人(main内部を書く人)と、分業ができるようになる。

隠蔽化

このような記述では、データ構造の中身を知らなくても、main で、setしてprintして…という処理の雰囲気は分かるさらに、set_NameAge()とか、print_NameAge() の処理の中身を知らなくても、設定するとか表示するとか…は予想できる

これは、NameAge というデータをブラックボックス化(隠蔽化)して捉えていると見れる。データ構造の中身を知らなくてもプログラムを理解できることは、データ構造の隠蔽化という。また、関数の中身を知らなくても理解できることは、手続きの隠蔽化という。

オブジェクト指向プログラミング

前述のように、プログラムを書く時には、データ構造とそのデータを扱う関数を一緒に開発する方が分かり易い。そこで、プログラム言語の文法自体を、データ構造その関数(メソッドと呼ぶ)をまとめてクラスとして扱うプログラムスタイルが、オブジェクト指向プログラミングの基本である。

class NameAge {
private:
   // データ構造の宣言
   char name[ 20 ] ;
   int  age ;

public:
   // メソッドの定義
   void set( char s[] , int a ) { // 初期化関数
      strcpy( name , s ) ;        // どのデータに対する処理かは省略できるので、
      age = a ;                   // データへのポインタ引数は不要。
   }
   void print() {                 // 表示関数
      printf( "%s %d¥n" , name , age ) ;
   }
} ;

void main() {
   NameAge saitoh ;
   saitoh.set( "t-saitoh" , 53 ) ; // set,printはpublicなので自由に使える。
   saitoh.print() ;
   // saitoh.age = 54 ; エラー:クラス外でprivateの要素は触れない。
}

このプログラムでは、saitoh というデータ(具体的なデータが割り当てられたものはオブジェクトと呼ぶ)に対して、set() , print() のメソッドを呼び出している。

# C++ではクラス毎に関数名を区別してくれるので、関数名もシンプルにset,printのようにかける。

オブジェクト指向では、データに対して private を指定すると、クラス以外でその要素やメソッドを扱うことができなくなる。一方 public が指定されたものは、クラス外で使っていい。これにより、クラスを設計する人と、クラスを使う人を明確に分けることができ、クラスを使う人が、クラス内部の変数を勝手に触ることを禁止できる。

プログラムを記述する時には、データ件数を数える時に、カウンタの初期化を忘れて動かないといった、初期化忘れも問題となる。オブジェクト指向のプログラム言語では、こういうミスを減らすために、データ初期化専用の関数(コンストラクタ)を定義することで、初期化忘れを防ぐことができる。

// コンストラクタを使う例
class NameAge {
   // 略
public:
   NameAge( char s[] , int a ) { // データ初期化専用の関数
      strcpy( name , s ) ;       //  コンストラクタと呼ぶ
      age = a ;
   }
   // 略
} ;
void main() {
   NameAge saitoh( "t-saitoh" , 53 ) ; // オブジェクトの宣言と初期化をまとめて記述できる。
   saitoh.print() ;
}

プログラムにオブジェクト指向を取り入れると、クラスを利用する人クラスを記述する人分業ができ、クラスを記述する人は、クラスを利用するプログラマーに迷惑をかけずにプログラムを修正できる。

この結果、クラスを記述する人はプログラムを常により良い状態に書き換えることができるようになる。このように、よりよく改善を常に行うことはリファクタリングと呼ばれ、オブジェクト指向を取り入れる大きな原動力となる。。

最近のC++なら

最近のオブジェクト指向プログラミングは、テンプレート機能と組み合わせると、単純リスト処理が以下のように書けてしまう。struct 宣言やmalloc()なんて出てこない。(^_^;

#include <iostream>
#include <forward_list>
#include <algorithm>

int main() {
  // std::forward_list<>線形リスト
  std::forward_list<int> lst{ 1 , 2 , 3 } ; // 1,2,3の要素のリストで初期化

  // リスト先頭に 0 を挿入
  lst.push_front( 0 ) ;


  // 以下のような処理を最新のC++なら...
  // * もともとのC言語なら以下のように書くだろう。
  //   for( struct List*p = top ; p != NULL ; p = p->next )
  //     printf( "%d¥n" , p->data ) ;

  // * 通常の反復子iteratorを使って書いてみる。
  //   auto は、lst の型推論。
  //   ちょっと前のC++なら型推論がないので、
  //   std::forward_list<int>::iterator itr = lst.begin() と書く。

  // * C++では演算子の処理をクラス毎に書き換えることができる。
  //   itr++ といっても、カウントアップ処理をする訳ではない。 
  for( auto itr = lst.begin() ;
       itr != lst.end() ;
       itr++ ) {
    std::cout << *itr << std::endl ;
  }

  // 同じ処理を algorithm を使って書く。
  std::for_each( lst.begin() ,
                 lst.end() ,
                 []( int x ) { // 配列参照のコールバック関数
                   std::cout << x << std::endl ;
                 } );

  // 特に書かなくてもデストラクタがlstを捨ててくれる。
  return 0 ;
}

テンプレート機能

テンプレート機能は、実際のデータを覚える部分の型を後で指定できるようにしたデータ構造を定義する機能。

template <class >
struct List {
   T   data ;
   struct List* next ;
} ;

int main() {
   List<int>    li ;  // 整数を要素とするList型の宣言
   List<double> ld ;  // 実数を要素とするList型の宣言
}

関数ポインタ

関数ポインタとコールバック関数

JavaScript のプログラムで、以下のようなコーディングがよく使われる。このプログラムでは、3と4を加えた結果が出てくるが、関数の引数の中に関数宣言で使われるfunctionキーワードが出てきているが、この意味を正しく理解しているだろうか?

このような (function()…)は、無名関数と呼ばれている。このような機能は、C言語では関数ポインタと呼ばれたり、新しいプログラム言語では一般的にラムダ式などと呼ばれる。

// JavaScriptの無名関数の例 3+4=7 を表示
console.log( (function( x , y ) {
                 return x + y ;
              })( 3 , 4 ) ) ; // 無名関数
console.log( ((x,y) => {
                 return x + y ;
              })( 3 , 4 ) ) ; // アロー関数

C言語の関数ポインタの仕組みを理解するために、以下のプログラムを示す。

int add( int x , int y ) {
   return x + y ;
}
int mul( int x , int y ) {
   return x * y ;
}
void main() {
   int (*f)( int , int ) ; // fは2つのintを引数とする関数へのポインタ
   f = add ;               // f = add( ... ) ; ではないことに注意
   printf( "%d¥n" , (*f)( 3 , 4 ) ) ; // 3+4=7
                 // f( 3 , 4 ) と書いてもいい
   f = mul ;
   printf( "%d¥n" , (*f)( 3 , 4 ) ) ; // 3*4=12
}

このプログラムでは、関数ポインタの変数 f を定義している。「 int (*f)( int , int ) ; 」 は、int型の引数を2つ持つ、返り値がint型の関数へのポインタであり、「 f = add ; 」では、f に加算する関数を覚えている。add に実引数を渡す()がないことに注目。

そして、「 (*f)( 3 , 4 ) ; 」により、実引数を3,4にて f の指し示す add を呼び出し、7 が答えとして求まる。

こういう、関数に、「自分で作った関数ポインタ」を渡し、その相手側の関数の中で自分で作った関数を呼び出してもらうテクニックは、コールバックとも呼ばれる。コールバック関数を使うC言語の関数で分かり易い物は、クイックソートを行う qsort() 関数だろう。qsort 関数は、引数にデータを比較するための関数を渡すことで、様々な型のデータの並び替えができる。

#include <stdio.h>
#include <stdlib.h>

// 整数を比較するコールバック関数
int cmp_int( int* a , int* b ) {
   return *a - *b ;
}
// 実数を比較するコールバック関数
int cmp_double( double* a , double* b ) {
   double ans = *a - *b ;
   if ( ans == 0.0 )
      return 0 ;
   else if ( ans > 0.0 )
      return 1 ;
   else
      return -1 ;
}

// ソート対象の配列
int    array_int[ 5 ] = { 123 , 23 , 45 , 11 , 53 } ;
double array_double[ 4 ] = { 1.23 , 12.3 , 32.1 , 3.21 } ;

void main() {
   // 整数配列をソート
   qsort( array_int , 5 , sizeof( int ) ,
          (int(*)(const void*,const void*))cmp_int ) ;
   for( int i = 0 ; i < 5 ; i++ )
      printf( "%d\n" , array_int[ i ] ) ;
   // 実数配列をソート
   qsort( array_double , 4 , sizeof( double ) ,
          (int(*)(const void*,const void*))cmp_double ) ;
   for( int i = 0 ; i < 5 ; i++ )
      printf( "%f\n" , array_double[ i ] ) ;
}

無名関数

コールバック関数を使っていると、データを比較するだけの関数とか簡単な短い処理が使われることが多い。こういった処理を実際に使われる処理と離れた別の場所に記述すると、プログラムが読みづらくなる。この場合には、その場で関数の名前を持たない関数(無名関数)を使用する。(最近のC++の文法なのでテストには出さない)

void main() {
   int (*f)( int , int ) ; // fは2つのintを引数とする関数へのポインタ
   f = []( int x , int y ) { return x + y } ; // add を無名関数化
   printf( "%d¥n" , (*f)( 3 , 4 ) ) ; // 3+4=7

   // mul を無名関数にしてすぐに呼び出す3*4=12 
   printf( "%d¥n" , []( int x , int y ) { return x * y ; }( 3 , 4 ) ) ;
   // メモ:C++11では、ラムダ式=関数オブジェクト
   //      C++14以降は、変数キャプチャなどの機能が追加されている。
}

演習(ハッシュ法)

ハッシュ法のプログラム(オープンアドレス法もしくはチェイン法)を用いて、
(1)名前と電話番号,(2)名前と住所,(3)名前と誕生日について、名前をキーとして検索するプログラムを作成せよ。

原則として「出席番号 % 3 + 1」の番号のテーマに取り組むこと。

レポートを作成する際には、ハッシュ関数を変更してどういった変化があるか確認せよ。
ハッシュサイズは、10〜20件程度で良い。

動的メモリ管理 malloc() と free()

C言語では、動的メモリ領域をどのように管理していくのか解説する。

局所変数とスタック

局所変数は、関数に入った時に作られるメモリ領域であり、関数の処理を抜けると自動的に開放されるデータ領域である。

関数の中で関数が呼び出されると、スタックには戻り番地情報を保存し、関数に移動する。最初の処理で局所変数領域が確保され、関数を終えると局所変数は開放される。
この局所変数の確保と開放は、最後に確保された領域を最初に開放される(Last In First Out)ことから、スタック上に保存される。

baz()の中で、「*((&c)+8) = 123 ;」を実行したら、bar()のxを書き換えられるかも…

動的メモリ領域とフリーリスト

動的なメモリ領域(ヒープ領域)は、malloc()関数で処理用のメモリを借りfree()関数で使わなくなったメモリを返却する。

この返却されたメモリ領域は、改めて malloc() が呼び出されたときに再利用を行う。この再利用するメモリ領域は、簡単に扱えるようにリスト構造にして保存する。この free された再利用候補のリスト構造は、free_list と呼ばれる。

mallocが一定サイズの場合

仕組みを理解する第1歩として、free_list の考え方を説明するために、malloc() でのメモリサイズが一定として説明を行う。free_list には、貸し出すためのメモリ空間をリスト構造で繋がった状態にしておく。

malloc() が呼び出される度に、free_list の先頭から貸し出すメモリを取り出し(a=malloc(),b=malloc(),c=malloc()まで)、free() が呼び出されると、返却されたメモリは、free_list の先頭につないでおく。

任意サイズのメモリ確保の場合

最初のステップでの説明は、mallocのメモリサイズを一定としていたが、本来は確保するメモリサイズが指定する。この場合は、以下の様に管理されている。mallocで貸し出されるメモリ空間には、ヒープメモリの利用者が使うブロックの前に、次のメモリブロックへのポインタとブロックサイズを記憶する領域をつけておく。こういったメモリブロックを free_list の考え方と同じようにリスト構造となるようにつないで保存されている。

この図の一番下の赤部分は、次のメモリブロックへのポインタとブロックサイズの大きさが20byteの場合の例。

malloc() で、指定されたサイズのものが、free_list の中にあれば、それを使う。malloc(40)

丁度いいサイズが無い場合は、それより大きいメモリブロックの後半を切り分けて、貸し出す。malloc(60)

free()の処理とメモリブロックの併合

この例の最後の処理では、20byte,60byte,40byte,50byteが併合された例。併合後のブロックサイズは、すこしいい加減に書いてある。

使用されていたメモリブロックが free() で返却された場合は、free_list につないでいく。ただし、単純にリストに繋ぐだけであれば、malloc(),free() を繰り返すと、小さなメモリブロックばかりになってしまい、大きいメモリのmalloc()ができなくなる。

そこで、free() で返却される際には、隣り合うメモリブロックと併合できるかを確認し、大きなメモリブロックになるような処理を行う。

また、隣り合うメモリブロックが併合できるかの判定が簡単になるように、free_listにつなぐ際は、次のメモリブロックへのポインタは、昇順となるように並べる。

一般的には、上記のようにmalloc(),free()を行うが(K&Rのmallocアルゴリズム)、mallocのサイズが小さい場合には小さいメモリブロック毎にnextブロックポインタやブロックサイズを記憶する場合、メモリのムダが多い。
そこで、最初に説明した一定サイズのmalloc()の手法で、8byte専用のfreelist,16byte専用のfreelist,32byte専用のfreelistのように2Nbyteのfreelistで管理する。10byteといった中途半端なサイズの時は、それより大きい16byteのfreelistを使う。(dlmallocのアルゴリズム)

ヒープメモリの断片化

ヒープメモリの malloc() , free() を繰り返すと、最悪、以下の図の様に、使用中領域(赤)とfreeされた未使用領域(黒)が交互に並ぶ状態が発生するかもしれない。この場合、全体の未使用領域の合計では十分なサイズでも、小さなメモリブロックばかりとなって、大きなメモリブロックを要求されても十分な大きさのメモリが見つからない状態が発生する場合がある。

この状態をヒープメモリの断片化といい、使用しづらい小さなメモリブロックはヒープホールと呼ばれる。

(補足) 断片化

断片化というと、OSではハードディスクの断片化(フラグメンテーション)を思い浮かべるかもしれない。ハードディスクの断片化とは、ファイル領域の割り当てとファイルの削除を繰り返すことで、ファイルのセクタが不連続となり、アクセス効率が悪くなる現象。OSによっては、ファイル実体の位置を動かすことで断片化を改善できる。以下の図のようにフラグメンテーションを防ぐための実体の移動を行う最適化はデフラグと呼ばれる。

上記の図では、上の青の図が断片化が発生している事例で、a1→a2,a2→a3の時にヘッド移動(シーク時間)が発生する。下の赤の図のように、デフラグ処理を施すことでシーク時間が減らせる。

Windows が 95,98,Me といった時代ではOSが不安定で、フラグメントが多く発生する場合Windowsがフリーズすることが多く、OSが不安定になったらデフラグを実行する…というテクニックが定番だった。最新のWindowsでは、デフラグが自動的に実行されるのでユーザが意識的に実行する機会はほぼなくなった。

ガベージコレクタ

ガベージコレクタ

では、循環リストの発生するようなデータで、共有が発生するような場合には、どのようにデータを管理すれば良いだろうか?
最も簡単な方法は、処理が終わっても、使い終わったメモリを返却しない、方法である。ただし、このままでは、メモリを使い切ってしまう。

そこで、廃棄処理をしないまま、ゴミだらけになってしまったメモリ空間を再利用するのが、ガベージコレクタ(一般的にはGCと略される)である。
ガベージコレクタは、貸し出すメモリ空間が無くなった時に起動され、

  1. すべてのメモリ空間に、「不要」の目印をつける。(unmark処理)
  2. 変数に代入されているデータが参照している先のデータは「使用中」の目印をつける。(mark処理-目印をつける)
  3. その後、「不要」の目印がついている領域は、だれも使っていないので回収する。(sweep処理-掃き掃除する)


この方式は、マークアンドスイープ法と呼ばれる。ただし、このようなガベージコレクタはメモリ空間が広い場合は、処理時間かかり、さらにこの処理中は、他の処理ができず処理が中断されるので、コンピュータの操作性という点では問題となる。

最近のプログラミング言語では、参照カウンタとガベージコレクタを取り混ぜた方式でメモリ管理をする機能が組み込まれている。このようなシステムでは、局所変数のような関数に入った時点で生成され関数終了ですぐに不要となる領域は、参照カウンタで管理し、大域変数のような長期間保管するデータはガベージコレクタで管理される。

大量のメモリ空間で、メモリが枯渇したタイミングでガベージコレクタを実行すると、長い待ち時間となることから、ユーザインタフェースの待ち時間に、ガベージコレクタを少しづつ動かすなどの方式もとることもある。

ガベージコレクタが利用できる場合、メモリ管理を気にする必要はなくなってくる。しかし、初心者が何も気にせずプログラムを書くと、使われないままのメモリがガベージコレクタの起動まで放置され、場合によってはメモリ不足による処理速度低下の原因となる場合もある。手慣れたプログラマーであれば、素早くメモリを返却するために、使われなくなった変数に意図的に null を代入するなどのテクニックを使う。

プログラム言語とメモリ管理機能

一般的に、C言語というとポインタの概念を理解できないと使えなかったり、メモリ管理をきちんとできなければ危険な言語という点で初心者向きではないと言われている。

C言語は、元々 BCPLB言語を改良してできたプログラム言語であった。これに、オブジェクト指向の機能を加えた C++ が作られた。C++ という言語の名前は、B言語→C言語と発展したので、D言語(現在はまさにD言語は存在するけど)と名付けようという意見もあったが、C++ を開発したビャーネ・ストロヴストルップは、ガベージコレクタのようなメモリ管理機能が無いことから、D言語を名乗るには不十分ということで、C言語を発展させたものという意味でC++と名付けている。

こういった中で、C++をベースとしたガベージコレクタなどを実装した言語としては、Java が挙げられる。オブジェクト指向をベースとしたマルチスレッドやガベージコレクタに加え、仮想マシンによる実行で様々なOS(やブラウザ)で動かすことができる。

最近注目されている言語の1つとして、C言語の苦手であった「メモリ安全性」や実行効率を考えて開発されたものに Rust が挙げられる。メモリ管理や効率などの性能から、最近では Linux の開発言語に Rust を部分的に導入するなどの計画も出ている。

共有のあるデータの取扱い

これまでの授業の中では、データを効率よく扱うためのデータ構造について議論をしてきた。これまでのプログラムの中では、データ構造のために動的メモリ(特にヒープメモリ)を多用してきた。ヒープメモリでは、malloc() 関数により指定サイズのメモリ空間を借りて、処理が終わったら free() 関数によって返却をしてきた。この返却を忘れたままプログラムを連続して動かそうとすると、返却されなかったメモリが使われない状態(メモリリーク)となり、メモリ領域不足から他のプログラムの動作に悪影響を及ぼす。

メモリリークを防ぐためには、malloc() で借りたら、free() で返すを実践すればいいのだが、複雑なデータ構造になってくると、こういった処理が困難となる。そこで、ヒープメモリの問題点について以下に説明する。

共有のあるデータの取扱の問題

リスト構造で集合計算の和集合を求める処理を考える。

// 集合和を求める処理
struct List* join( struct List* a , struct List* b )
{  struct List* ans = b ;
   for( ; a != NULL ; a = a->next )
      if ( !find( ans , a->data ) )
         ans = cons( a->data , ans ) ;
   return ans ;
}

void list_del( struct List* p )
{                            // ダメなプログラムの例
   while( p != NULL ) {      // for( ; p != NULL ; p = p->next )
      struct List* d = p ;   //    free( p ) ;
      p = p->next ;
      free( d ) ;
   }    
}

void main() {
   // リストの生成
   struct List* a = cons( 1 , cons( 2 , cons( 3 , NULL ) ) ) ;
   struct List* b = cons( 2 , cons( 3 , cons( 4 , NULL ) ) ) ;
   struct List* c = join( a , b ) ; // c = { 1, 2, 3, 4 }
                                     //          ~~~~~~~ ここは b
   // a,b,cを使った処理

   // 処理が終わったのでa,b,cを捨てる
   list_del( a ) ;
   list_del( b ) ;
   list_del( c ) ; // list_del(b)ですでに消えている
}                  // このためメモリー参照エラー発生

このようなプログラムでは、c=join(a,b) ; が終わると下の図のようなデータ構造となる。しかし処理が終わってリスト廃棄list_del(a), list_del(b), listdel(c)を行おうとすると、bの先のデータは廃棄済みなのに、list_del(c)の実行時に、その領域を触ろうとして異常が発生する。

実体をコピーする方法

こういった共有の問題の一つの解決法としては、共有が発生しないように実体を別にコピーする方法もある。しかし、この方法はメモリがムダになる場合もあるし、List内のデータを修正した時に、実体をコピーした部分でも修正が反映されてほしい場合に問題となる。

// 実体をコピーする(簡潔に書きたいので再帰を使う)
struct List* copy( struct List* p ) {
   if ( p != NULL )
      return cons( p->data , copy( p->next ) ) ;
   else
      return NULL ;
}

// 共有が無い集合和を求める処理
struct List* join( struct List* a , struct List* b )
{
   struct List* ans = copy( b ) ;
   //                 ~~~~~~~~~実体をコピー
   for( ; a != NULL ; a = a->next )
      if ( !find( ans , a->data ) )
        ans = cons( a->data , ans ) ;
   return ans ;
}

参照カウンタ法

上記の問題は、b の先のリストが c の一部とデータを共有しているために発生する。この解決方法として簡単な方法では、参照カウンタ法が用いられる。

参照カウンタ法では、データを参照するポインタの数をデータと共に保存する。

  • データの中にポインタ数を覚える参照カウンタを設け、データを生成した時に1とする。
  • 処理の中で共有が発生すると、参照カウンタをカウントアップする。
  • データを捨てる際には、参照カウンタをカウントダウンし、0になったら本当にそのデータを消す。
struct List {
   int          refc ; // 参照カウンタ
   int          data ; // データ
   struct List* next ; // 次のポインタ
} ;

struct List* cons( int x , struct List* p ) {
   struct List* n = (struct List*)malloc( sizeof( struct List* ) ) ;
   if ( n != NULL ) {
      n->refc = 1 ; // 初期状態は参照カウンタ=1
      n->data = x ;
      n->next = p ;
   }
   return n ;
}

struct List* copy( struct List* p ) {
   p->refc++ ;  // 共有が発生したら参照カウンタを増やす。
   return p ;
}
// 集合和を求める処理
struct List* join( struct List* a , struct List* b )
{
   struct List* ans = copy( b ) ;
   //                 ~~~~~~~~~共有が発生するのでrefc++
   for( ; a != NULL ; a = a->next )
      if ( !find( ans , a->data ) )
         ans = cons( a->data , ans ) ;
   return ans ;
}

void list_del( strcut List* p ) {  // 再帰で全廃棄
   if ( p != NULL
        && --(p->refc) <= 0 ) {    // 参照カウンタを減らし
      //   ~~~~~~~~~~~
      list_del( p->next ) ;        // 0ならば本当に消す
      free( p ) ;
   }
}

int main() { // リストの生成
   struct List* a = cons( 1 , cons( 2 , cons( 3 , NULL ) ) ) ;
   struct List* b = cons( 2 , cons( 3 , cons( 4 , NULL ) ) ) ;
   struct List* c = join( a , b ) ;

   // a,b,cを使った処理

   // 処理が終わったのでa,b,cを捨てる
   list_del( a ) ;  // aの要素は全部refc=1なので普通に消えていく
   list_del( b ) ;  // bは、joinの中のcopy時にrefc=2なので、
                    // この段階では、refc=2 から refc=1 になるだけ
   list_del( c ) ;  // ここで全部消える。
}

ただし、参照カウンタ法は、循環リストではカウンタが0にならないので、取扱いが苦手。

unix i-nodeで使われている参照カウンタ

unixのファイルシステムの基本的構造 i-node では、1つのファイルを別の名前で参照するハードリンクという機能がある。このため、ファイルの実体には参照カウンタが付けられている。unix では、ファイルを生成する時に参照カウンタを1にする。ハードリンクを生成すると参照カウンタをカウントアップ”+1″する。ファイルを消す場合は、基本的に参照カウンタのカウントダウン”-1″が行われ、参照カウンタが”0″になるとファイルの実体を消去する。

以下に、unix 環境で 参照カウンタがどのように使われているのか、コマンドで説明していく。

$ echo a > a.txt
$ ls -al *.txt
-rw-r--r-- 1 t-saitoh t-saitoh 2 12月 21 10:07 a.txt
          ~~~ # ここが参照カウンタの値
$ ln a.txt b.txt      # ハードリンクでコピーを作る
$ ls -al *.txt
-rw-r--r-- 2 t-saitoh t-saitoh 2 12月 21 10:07 a.txt
-rw-r--r-- 2 t-saitoh t-saitoh 2 12月 21 10:07 b.txt
          ~~~ # 参照カウンタが増えているのが分かる
$ rm a.txt            # 元ファイルを消す
$ ls -al *.txt
-rw-r--r-- 1 t-saitoh t-saitoh 2 12月 21 10:07 b.txt
          ~~~ # 参照カウンタが減っている
$ ln -s b.txt c.txt   # シンボリックリンクでコピーを作る
$ ls -al *.txt
-rw-r--r-- 1 t-saitoh t-saitoh 2 12月 21 10:07 b.txt
lrwxrwxrwx 1 t-saitoh t-saitoh 5 12月 21 10:10 c.txt -> b.txt
$ rm b.txt            # 元ファイルを消す
$ ls -al *.txt
lrwxrwxrwx 1 t-saitoh t-saitoh 5 12月 21 10:10 c.txt -> b.txt
$ cat c.txt           # c.txt は存在するけどその先の実体 b.txt は存在しない
cat: c.txt: そのようなファイルやディレクトリはありません

文字列のハッシュ関数

文字列のハッシュ値

ここまでで説明した事例は、電話番号をキーとするものであり、余りを求めるだけといったような簡単な計算で、ハッシュ値が求められた。しかし、一般的には文字列といったような名前から、ハッシュ値が欲しいことが普通だろう。

ハッシュ値は、簡単な計算で、見た目デタラメな値が求まればいい。 (ただしく言えば、ハッシュ値の出現確率が極力一様であること)。一見規則性が解らない値として、文字であれば文字コードが考えられる。複数の文字で、これらの文字コードを加えるなどの計算をすれば、 偏りの少ない値を取り出すことができる。

int hash_func( char s[] ) {
   int sum = 0 ;
   for( int i = 0 ; s[i] != '¥0' ; i++ ) {
      sum = sum + s[i] ;
   }
   return sum % SIZE ;
}

文字列順で異なる値となるように

前述のハッシュ関数は、”ABC”さんと”CBA”さんでは、同じハッシュ値が求まってしまう。文字列順で異なる値が求まるように改良してみる。

int hash_func( char s[] ) {
   int sum = 0 ;
   for( int i = 0 ; s[i] != '¥0' ; i++ ) {
      sum = sum*2 + s[i] ;
      // sum = (sum * 小さい素数 + s[i]) % 大きい素数 ;
   }
   return sum % SIZE ;
}

上記のプログラムの、sum = sum*2 + s[i] では、2倍していった数を最後に SIZE で割っているだけなので、文字が長い場合文字コードの値の違いが sum の中に残らない場合も考えられる。こういった場合には、以下のような方法も考えられる。大きな素数で割ることで、余りの中に、元の数の値の違いの影響が残る。これは、疑似乱数生成での剰余法(or 線形合同法)の考え方を取り入れた方法ともいえる。

#define PRIME_B 大きな素数
#define PRIME_A 小さな素数

int hash_func( char s[] ) {
   int sum = 0 ;
   for( int i = 0 ; s[i] != '¥0' ; i++ ) {
      sum = (sum * PRIME_A + s[i]) % PRIME_B ;
   }
   return sum % SIZE ;
}

プログラムの処理時間の測り方

今回の課題レポートでは、テスト素点が良くレポート提出でも加点が少ないと思われる人でも、まじめに取り組んだレポート提出が多かった。この中で、興味深いレポートで、2分探索木の検索が偏っていたらO(N),バランスが良ければO(log N)の検証をしているものがあった。ただ、計測方法がちょっと残念だったので解説。

処理時間の計測方法

速度の検証をするにしてもデータ数も少なく、検索は一瞬で終わってしまうので、以下のような 1000 回ループで時間を測定していた。1000回といったループで計測するのは正しいアプローチ。

for( int i = 0 ; i < 1000 ; i++ ) {
   if ( find( top , key ) )
      printf( "みつかった" ) ;
   else
      printf( "みつからない" ) ;
}

ただ、この方法では、find() の処理時間以上に、printf() の処理時間の方が問題となる。

printf の処理時間

printf は、ただでさえも、第一引数の中にある “%” フォーマットの分析をするという面倒な処理をしているので、かなり複雑な処理をしている関数。メモリ容量の少ない組込み系のシステムを構築する時は、%フォーマットの分析のコードも大きくなるので、使うのを避けることも多い。

また、printf は、%フォーマットの解析に加え、出力バッファリングなどを経て、最終的に文字を標準出力に出力する。標準出力への出力が発生すると、OSのシステムコールを呼び出し、結果を表示するターミナルプログラムは、画面への文字の出力や、画面からはみ出た文字のスクロールアップなどの処理を行う。このため、find() 関数の処理時間以上に手間がかかっている。このため、精度の低い結果となってしまっている。

つまり、自分の関数の処理時間を計測したいなら、余計な時間がかかる printf などの入出力処理は時間計測に含めないこと。

time コマンド

プログラムの処理時間を測る場合には、測定対象のプログラムの処理時間の他にも、最近のOSではマルチタスクだからこそ、他の並列処理にとられている時間、OSでマルチタスクの切替に係る時間なども含まれてしまう。

このため Linux 環境でプロセスの処理時間を測定する場合には、time コマンドを用いる。

((( ちょっと時間のかかりそうな /usr/lib 配下のファイルの一覧出力 )))
$ find /usr/lib -type f -print
(略)
((( time コマンドは、time の後ろに計測したい処理を書く )))
$ time find /usr/lib -type f -print 
(略)
0.00s user 0.33s system 26% cpu 1.242 total
((( 比較のために出力を /dev/null に捨てて実行 )))
$ time find /usr/lib -type f -print > /dev/null
find /usr/lib -type f -print > /dev/null
0.00s user 0.25s system 87% cpu 0.287 total

この例では、find 自体の処理時間 user 時間 0秒、find が 出力命令のようなシステムコールを実行したことによる OS の処理時間 system 時間 0.33 秒、出力なども含めて見かけ上の本当の処理時間が 1.242 秒というのがわかる。

この結果を見ても、如何に出力処理が遅いのかが分かる。

だからこそ、自分のプログラムの処理時間として使う場合は、user 時間の部分を使うこと。

プロファイラによる解析

プログラムの処理が遅い場合の原因を究明する場合には、プロファイラというプログラムを用いることが多い。

例えば gcc などのコンパイラのためのプロファイラの gprof では、プログラムの処理に一定時間で割り込みをかけ、そのタイミング毎にどの関数の処理中だったのかを調べることで、全体の処理時間の何パーセントをその関数の処理をしていた…ということを計測できる。(統計的プロファイラ)

((( 測定対象のプログラムにプロファイラ用の情報を埋め込んでコンパイル )))
$ gcc -pg foobar.c
((( プログラムを実行すると gmon.out という統計情報が出力される )))
$ ./a.out
((( gprof で統計結果の確認 )))
$ gprof ./a.out gmon.out

といっても、割り込みをかけて計測しているので、プロファイラの結果は精度の高いデータとは言えない。

システム

最新の投稿(電子情報)

アーカイブ

カテゴリー