*p;
if (!(t -> left)) { p = t; t = t -> right; delete p; }
else if (!(t -> right)) { p = t; t = t -> left; delete p; }
else { p = t -> left;
while (p -> right) p = p -> right;
t -> inf = p -> inf;
Del (t -> left, p -> inf);
}
}
}
template
void BinOrdTree::Create ()
{ DeleteTree (root);
T x; char c;
do {
cout << “Enter element: “;
cin >> x;
AddNode (x);
cout << “Enter more (y/n)? “;
cin >> c;
} while (c == ‘y’ || c == ‘Y’);
}
14. Основни конструкции в езиците за функционално програмиране. Дефиниране и използване на функции. Списъци. Функции от
по-висок ред за работа със списъци. Потоци.
Във функционалното програмиране програмата се изгражда в дескриптивен стил – описват се свойствата на желания резултат, докато алгоритъмът за неговото намиране не е съществен. Най-често програмата е поредица от дефиниции на функции, които имат за цел да пресмятат някакъв резултат. Тези функции не предизвикват странични ефекти подобно на програмите при процедурното програмиране. Това води до по лесно намиране и отстраняване на грешки. В програмите няма операции за цикли, за условен или безусловен преход, операции за присвояване. Итерациите се реализират чрез рекурсия. При функционалното програмиране има две основни дейности – дефиниции на функции и прилагане на функции за извличане на резултат.
Тук ще разглеждаме езика Scheme, който е пример за нестрог функционален език и е диалект на езика Lisp.
Математически основи на езика Lisp се основава на -смятането на Чърч. Основната структура в този език са списъците и чрез тях се моделират обекти от реалния свят.
Езикът Scheme притежава три механизма за изразяване:
-
примитивни изрази;
-
средства за комбинация;
-
средства за абстракция.
Основна синтактична единица в езика Scheme е изразът. Неформално работният цикъл на интерпретатора на Scheme е следния:
-
чете израз (read);
-
оценява израз (evaluate);
-
извежда оценката (print).
Изразите могат да бъдат примитивни или съставни. Примитивните изрази още се наричат атомарни изрази или атоми, а съставните се наричат комбинации.
Примитивните изрази се делят на:
-
числа – с фиксирана или плаваща точка – на практика с неограничен размер;
-
символни низове – поредица от знаци, заградени в кавички (“ “);
-
вградени функции – например +, -, *, / и др.;
-
символни атоми – в процедурните езици са известни като идентификатори – поредица от букви или цифри, която трябва да започва с буква.
Оценката на примитивен израз директно се свързва с неговото име:
-
оценката на число е самото число;
-
оценката на символен низ е самия низ;
-
оценката на вградена функция е така наречения функционален (процедурен) обект – двойка съдържаща указател към среда и поредица от машинни инструкции, които реализират съответната функция;
-
за да има оценка един символен атом, той трябва да означава име на променлива или функция, т.е. предварително трябва да е дефиниран, ако не е дефиниран оценката на символния атом е неопределена.
Списък в Scheme е редица от обекти, оградени в кръгли скоби и разделени с един или повече интервали, допуска се влагане на списъци и празен списък, който се означава с ().
Семантично списъците биват няколко вида и оценката им зависи от техния вид, ако искаме независимо от вида на списъка оценката му да бъде самия списък, използваме цитат по следния начин: (quote <списък>) или ‘<списък>.
Съставните изрази (комбинациите) са обръщения към функции. Функциите могат да бъдат вградени или дефинирани от потребителя. Съставните изрази синтактично се означават чрез списък по следния начин:
(<функция> <ФактП 1> <ФактП 2> ... <ФактП n>).
Числото n е броят на допустимите аргументи на функцията. Често първият аргумент в съставен израз се нарича оператор, а фактическите параметри се наричат операнди.
Оценяването на комбинация се извършва по следното общо правило:
-
отляво надясно се оценяват отделните подизрази на комбинацията;
-
оценката на оператора, която задължително е функция, се прилага върху оценките на операндите, които трябва да бъдат коректни по брой и тип.
Има изключение от общото правило при така наречените специални форми. Всяка специална форма си има собствено правило за оценяване.
Чрез средствата за абстракция, обектите на езика могат да се именуват и обработват като едно цяло. Основно такова средство е специалната форма define. Тя има две разновидности: за дефиниране на променливи и за дефиниране на процедури.
Синтаксисът за дефиниране на променливи е следния:
(define <име_на_променлива> <израз>).
Тук <име_на_променлива> е произволен символен атом, <израз> е произволен израз. Оценката на израза е <име_на_променлива>, но като страничен ефект се получава свързване на символния атом <име_на_променлива> с оценката на <израз> в специална работна памет, наречена среда. След като този израз е оценен, оттам нататък оценката на <име_на_променлива> ще бъде оценката на <израз>.
Особеността на define е, че нейният втори аргумент не се оценява, затова тя е специална форма.
Синтаксисът за дефиниране на процедури е следния:
(define (<име_на_процедура> <ФормП1> …<ФормПn>) <тяло>).
Тук <име_на_процедура> и <ФормПi>, i = 1, …, n са символни атоми, <тяло> е редица от изрази, разделени с един или повече интервали. Оценката на израза е <име_на_процедура>, но като страничен ефект в средата, в която се извършва свързването се създава нов процедурен обект – той съдържа указател към текущата среда и код. Кодът се състои от параметрите
(<ФормП1>, …, <ФормПn>) и тяло (изразите в редицата <тяло>).
За да може да се реализира именуването е нужно да се поддържа памет, където да се съхраняват двойки от вида (име, обект).
В езика Scheme при оценяването се използва т.н. модел на средите. При него, паметта, която служи за съхраняване на двойките (име, обект) се нарича среда. В началото на оценяването имаме дефинирана само една среда – наричаме я глобална. В процеса на оценяване могат да възникнат и други среди. Когато се създава нова среда тя винаги се явява разширение на вече съществуваща среда. Когато се извършва оценяване, то винаги се прави в контекста на някоя среда. Например, нека имаме средите E0, E1, …, En, като E0 е глобалната среда и средата Ei е разширение на средата Ei-1, i = 1, …, n.
Да предположим, че търсим оценката на името A в En. В модела на средите това търсене се извършва по следния начин: започвайки от En, последователно обхождаме средите в обратен ред на разширяването (от Ei към Ei-1), до първия момент в който достигнем до среда Ek, в която е дефинирана двойка (А, обект). Тогава процесът на търсене спира и оценката на A е съответният обект. При това е възможно да е дефинирана двойка (А, обект1) в среда Ej при j < k, но това не променя нищо (локалните имена скриват глобалните).
Както вече казахме, оценяването на един съставен израз (комбинация) представлява обръщения към процедура.
Нека имаме следната комбинация:
(<процедура> <ФактП1> …<ФактПn>).
Ще опишем как се извършва нейното оценяване в модела на средите. Нека оценяването на комбинацията се извършва в средата E. <процедура> е израз, който определя процедурата, към която се обръщаме, <ФактПi>, i = 1, …, n са фактическите параметри на обръщението. Първото което се прави е в средата E да се оценят <процедура> и <ФактПi>, i = 1, …, n. За да е коректно оценяването, оценката на <процедура> задължително трябва да е процедурен обект и фактическите параметри трябва да съответстват на формалните параметри по брой, тип и смисъл.
Нека указателят в процедурния обект, който е оценка на <процедура> сочи към средата E. Тогава се създава нова среда E, която е разширение на E и в E всички формалните параметри на процедурния обект се свързва с оценките на съответните им фактически параметри. След това в контекста на средата E се извършва последователно оценка на изразите от тялото на процедурния обект. Оценката на последния израз от тялото е оценка на първоначалната комбинация в средата E.
Описаният модел на средите реализира т.н. апликативен модел на оценяване (неговата същност е, че първо се оценяват операндите и след това към получените оценки се прилага оценката на оператора). Съществува и друг модел на оценяване – т.н. нормален модел. При него, при извършване на процедурно обръщение се оценява само оператора, но не и операндите – те се заместват в тялото на оценката на оператора на мястото на съответните им формални параметри без да се оценяват предварително и след това се оценява тялото на оценката на оператора.
Може да се докаже, че ако процесът на оценяване по апликативния модел завършва, то процесът на оценяване по нормалния модел също завършва и в резултат се получава една и съща оценка. Съществуват, обаче, много програми, които завършват изпълнението си по нормалния модел на оценяване, но не завършват по апликативния. Затова казваме, че нормалният модел е по-общ от апликативния. Езикът Scheme използва моделът на средите, в основата на който стои апликативният модел.
В някои случаи, обаче, например при работа с отложени безкрайни обекти Scheme използва нормалният модел.
На всяка дефинирана процедура може да се гледа както декларативно, така и императивно (като процес, който ще доведе до получаване на търсения резултат). Процес – това е абстрактно понятие, описващо развитието на пресмятането във времето, т.е. преобразуването на входните данни стъпка по стъпка до получаване на резултата. Можем да класифицираме процесите в два основни класа – рекурсивни и итеративни. Рекурсивните процеси се делят на линейно рекурсивни и дървовидно рекурсивни. Итеративните се делят на линейни итеративни и нелинейни итеративни (логаритмични и др.).
За да сравним линейно рекурсивните и линейно итеративните процеси ще реализираме по два начина функцията факториел.
(define (fact n)
(if (= n 0) 1 (* n (fact (- n 1)))))
(define (fact n)
(define (iterfact result m)
(if (> m n) result (iterfact (* result m) (+ m 1))))
(iterfact 1 1)
)
Оценяването на (fact 3) поражда следните процеси:
(fact 3) (* 3 (fact 2)) (* 3 (* 2 (fact 1))) (* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1))) (* 3 (* 2 1)) (* 3 2) 6,
(fact 3) (iterfact 1 1) (iterfact 1 2) (iterfact 2 3) 6.
Забелязваме, че в първия случай имаме разширяване и след това свиване на изчислителния процес, като при това имаме редица отложени операции. Порядъка на отложените операции е линейна функция на размера на входа и затова процесът в този случай процесът се нарича линейно рекурсивен.
При втория случай нямаме разширяване и свиване. Изчислителният процес напълно се описва от краен брой променливи на състоянието, правила които описват как тези променливи се променят при преминаване на едно състояние в друго и условие за край. Порядъка на броя на смените на състояния е линейна функция на размера на входа и затова процесът в този случай се нарича линейно итеративен.
Когато в своята дефиниция една процедура извиква себе си повече от веднъж, то забелязваме че броят на отложените операции става експоненциален. Процесите, които пораждат такива процедури наричаме дървовидно рекурсивни. Добър пример е процедурата за намиране на n-тото число на Фибоначи:
(define (fib n)
(if (= n 0) 1
(if (= n 1) 1
(+ (fib (- n 1)) (fib (- n 2))))))
Оценяването на (fib 4) поражда следния процес:
Вижда се, че това е твърде неефективна реализация, тъй като се правят излишни изчисления – (fib 2) се оценява 2 пъти, (fib 1) се оценява 3 пъти, (fib 0) се оценява 2 пъти.
Много по-ефективно е следното решение:
(define (fib n)
(define (iterfib a b counter)
(if (= counter n) а
(iterfib b (+ a b) (+ counter 1))))
(iterfib 1 1 0))
Оценяването на комбинацията (fib 4) се извършва по следния начин: (fib 4) (iterfib 1 1 0) (iterfib 1 2 1) (iterfib 2 3 2) (iterfib 3 5 3) (iterfib 5 8 4) 5. Процесът е линейно итеративен.
Всяка процедура, която манипулира с процедурни обекти се нарича процедура от по-висок ред. По-специално процедурите от по-висок ред получават процедурни обекти като параметри или връщат процедурни обекти като резултат.
Предаването на процедурите като параметри дава възможност за осъществяване на по-голямо ниво на абстракция в програмите на Scheme. Синтактично, то се осъществява по начина, по който се предават обикновени параметри. Като пример ще реализираме абстракцията f (a) + f (next (a)) … + f (b). Тук чрез next се получава следващата стъпка, сумирането започва от a и продължава до b.
(define (sum a b next f)
(if (> a b) 0
(+ (f a) (sum (next a) b next f))))
По-ефективен е следният линейно итеративен вариант:
(define (sum a b next f)
(define (itersum i result)
(if (> i b) result
(itersum (next i) (+ result (f i)))))
(itersum a 0)
)
Примерно използване: (sum 1 5 (lambda (x) (+ x 1)) (lambda (x) x)) се оценява до 1+2+3+4+5 = 15.
Оценката на една комбинация може да бъде процедурен обект. Това е съвсем лесно за реализация – оценката на последният израз в тялото тряба да е процедурен обект. Като пример ще дефинираме процедура, която има един аргумент процедура и връща нейната n-та експонента (функцията, приложена n-пъти върху аргумента си):
(define (exponent f n)
(if (= n 0) (lambda (x) x)
(lambda (x) (f ((exponent f (- n 1)) x)))))
В случая е използвана специалната форма lambda, която не оценя аргументите си и се използва за създаване на анонимен процедурен обект в текущата среда.
Примерно използване:
((exponent (lambda (x) (* x x)) 2) 3) се оценява до (32)2 = 81.
Основното предназначение на езика за функционално програмиране Scheme е работа със списъци. Списъците се реализират чрез точкови двойки. Точковата двойка е съставен тип данни в Scheme и тя представлява указател към двойна клетка, състояща се от лява и дясна част. Тези части сами по себе си могат да са точкови двойки или атоми. За да можем да работим с точкови двойки са необходими операция за конструиране на точкова двойка, операция за извличане на лявата част на точкова двойка и операция за извличане на дясната част на точкова двойка. Въпреки, че програмистите могат сами да си дефинират такива операции, за по-голяма ефективност те обикновено са вградени. В Scheme имаме:
(cons a b) – конструира точкова двойка с лява част оценката на a и дясна част оценката на b;
(car x) – извлича лявата част на оценката на x, която трябва задължително да е точкова двойка;
(cdr x) – извлича дясната част на оценката на x, която трябва задължително да е точкова двойка.
Списъкът е крайна редица от обекти от произволен тип, възможно празна. В Scheme списъците се дефинират посредством точкови двойки по следния начин: празният списък () е списък и ако L е списък и a е произволен обект, то точковата двойка с лява част a и дясна част L е списък. С вградените операции на Scheme списъкът 1>, 2>, …, n> може да се конструира така:
(cons 1> (cons 2> …(cons n> ())…)). Понякога не е удобно да се конструира списък по този начин и тогава може да се използва примитивната процедура list: (list 1> 2> …n>). Също може да се използва и специалната форма quote:
(quote (1> 2> … n>)) или в съкратен вариант
‘(1> 2> … n>).
Отделните елементи на списъците се извличат с помощта на подходящо комбиниране на car и cdr. Примери: (предполагаме, че оценката на <списък> е някакъв списък)
(car <списък>) – връща първия елемент на списъка;
(cdr <списък>) – връща списъка с премахнат първи елемент.
И в двата случая, ако оценката на <списък> е (), то оценката на комбинацията е неопределена. Други примери:
(car (cdr <списък>)) – връща втория елемент на списъка;
(car (cdr (cdr <списък>))) – връща третия елемент на списъка.
За синтактично удобство се допускат специални форми от вида
cxxxxr, където x е една от буквите a или d. Например:
(cadr <списък>) (car (cdr <списък>));
(caddr <списък>) (car (cdr (cdr <списък>))).
В езика Scheme има множество вградени операции за работа със списъци, но за пълнота ще разгледаме реализациите на някои операции.
Намиране на дължината на списък:
(define (length l)
(if (null? l) 0 (+ 1 (length (cdr l)))))
Вграденият предикат null? приема един аргумент и връща #t, ако този аргумент е празният списък и #f, в противен случай.
Намиране на k-тия елемент на списък, като номерацията започва от 1 (операцията има смисъл когато k не надвишава дължината на списъка, в частност операцията няма смисъл за празен списък):
(define (kth_el l k)
(if (= k 1) (car l) (kth_el (cdr l) (- k 1))))
Конкатенация на списъци:
(define (append l1 l2)
(if (null? l1) l2 (cons (car l1) (append (cdr l1) l2))))
Обръщане на списък (вариант с append):
(define (reverse l)
(if (null? l) l
(append (reverse (cdr l)) (list (car l)))))
Обръщане на списък (итеративен вариант без append):
(define (reverse l)
(define (helprev l1 result)
(if (null? l1) result (helprev (cdr l1) (cons (car l1) result))))
(helprev l ()))
Проверка за принадлежност към списък:
(define (member x l)
(cond ((null? l) #f)
((equal? x (car l)) #t)
(else (member x (cdr l)))))
Вграденият предикат equal? приема два аргумента и връща стойност #t, ако тези два аргумента имат еднакво представяне и #f, в противен случай.
Под списък с вложения разбираме списък, елементите на който може да са списъци. Още казваме, че работим със списъци на произволно ниво.
Намиране на броя на атомите в списък на произволно ниво: (вариант, в който празният списък не се брои за атом):
(define (deep_count_atoms l)
(cond ((null? l) 0)
((atom? l) 1)
(else (+ (deep_count_atoms (car l)) (deep_count_atoms (cdr l))))))
(вариант, в който празният списък се брои за атом):
(define (deep_count_atoms l)
(cond ((null? l) 0)
((atom? (car l)) (+ 1 (deep_count_atoms (cdr l))))
(else (+ (deep_count_atoms (car l)) (deep_count_atoms (cdr l))))))
Разликата е следната: например, (deep_count_atoms ‘(()()())) в първия вариант се оценява с 0, а във втория вариант с 3.
Вграденият предикат atom? приема един аргумент и връща стойност #t, ако този аргумент не е точкова двойка (т.е. е атом) и #f, в противен случай (ако не е атом).
Обръщане на списък на произволно ниво:
(define (deep_reverse l)
(if (atom? l) l
(append (deep_reverse (cdr l)) (list (deep_reverse (car l))))))
Ще разгледаме някои процедури от по-висок ред за работа със списъци.
Първата процедура извършва комбиниране (акумулиране) на елементите на списък:
(define (accum l combiner init_value)
(if (null? l) init_value
(combiner (car l) (accum (cdr l) combiner init_value))))
С помощта на тази процедура можем да намерим сумата на елементите на списък по следния начин: (accum l + 0), произведението на елементите на списък по следния начин: (accum l * 1), можем да създадем копие на списък по следния начин: (accum l cons ()) и освен това можем да реализираме функцията append с променлив брой аргументи:
(accum ‘(1> 2> …n>) append ()).
Втората процедура е за трансформиране на списък чрез прилагане на една и съща процедура върху елементите му.
(define (map f l)
(if (null? l) l (cons (f (car l)) (map f (cdr l)))))
Третата процедура е за филтриране на елементите на списък, т.е. за образуване на подсписък от елементите на списъка, които удоволетворяват някакво условие (филтър).
Ще я реализирамe в два варианта – чрез рекурсивен процес и чрез използване на вече дефинираните функции за акумулиране и за трансформация.
Рекурсивен вариант:
(define (filter l pred)
(cond ((null? l) l)
((pred (car l)) (cons (car l) (filter (cdr l) pred)))
(else (filter (cdr l) pred))))
Решение чрез трансформация:
(define (filter l pred)
(accum (map (lambda (x) (if (pred x) (list x) ())) l) append ()))
Ако един елемент минава през филтъра, той се замества със списък от себе си, в противен случай се замества с празен списък. След това върху елементите на получения списък се изпълнява append с променлив брой аргументи.
Потокът е съставна структура от данни, която се явява обобщение на структурата списък. Потокът представлява крайна или безкрайна редица от елементи от произволен тип. Възможен е пряк достъп само до първия елемент на потока. До останалите елементи достъпът е последователен и се осъществява чрез форсиране.
Нека редицата a1, a2, …, an, … е поток. В Scheme тази редица се представя като точкова двойка, която има за лява част представянето на a1 –